diff --git a/backend/__tests__/fixtures/hostedMcp/posthog-selected-tools-2026-10-21.json b/backend/__tests__/fixtures/hostedMcp/posthog-selected-tools-2026-10-21.json new file mode 100644 index 0000000000..8932da0a39 --- /dev/null +++ b/backend/__tests__/fixtures/hostedMcp/posthog-selected-tools-2026-10-21.json @@ -0,0 +1,1435 @@ +{ + "measuredAt": "2026-10-21T15:49:08.271Z", + "server": "https://mcp.posthog.com/mcp?mode=tools", + "requestedScope": null, + "toolCountInFullCapture": 780, + "tools": [ + { + "name": "insights-list", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "created_by": { + "description": "JSON-encoded array of user IDs. Only returns insights whose `created_by` is in the list, e.g. `[1,42]`.", + "type": "string" + }, + "created_date_from": { + "description": "Filter by `created_at > created_date_from`. Accepts absolute or relative dates.", + "type": "string" + }, + "created_date_to": { + "description": "Filter by `created_at < created_date_to`. Accepts absolute or relative dates.", + "type": "string" + }, + "dashboards": { + "description": "JSON-encoded array of dashboard IDs. Returns insights attached to every listed dashboard (AND).", + "type": "string" + }, + "date_from": { + "description": "Filter by `last_modified_at > date_from`. Accepts absolute dates (`2025-04-23`) or relative strings (`-7d`, `-1m`).", + "type": "string" + }, + "date_to": { + "description": "Filter by `last_modified_at < date_to`. Accepts absolute dates or relative strings.", + "type": "string" + }, + "display": { + "description": "Comma-separated chart types, e.g. `ActionsLineGraph,ActionsBar`. Only returns trends and SQL insights shown as one of them. A trends insight with no chart type set counts as `ActionsLineGraph`.", + "type": "string" + }, + "exclude_bi": { + "description": "Exclude Business intelligence worksheets from the insight list.", + "type": "boolean" + }, + "favorited": { + "description": "Include this parameter (any value) to restrict results to insights marked as favorited.", + "type": "boolean" + }, + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "insight": { + "description": "Restrict to a single insight type. `JSON` matches non-wrapper query insights; `SQL` matches HogQL queries; `BI` matches editable worksheets.", + "type": "string", + "enum": [ + "BI", + "FUNNELS", + "JOURNEYS", + "JSON", + "LIFECYCLE", + "PATHS", + "RETENTION", + "SQL", + "STICKINESS", + "TRENDS" + ] + }, + "last_viewed_date_from": { + "description": "Filter by `last_viewed_at > last_viewed_date_from`. Accepts absolute or relative dates.", + "type": "string" + }, + "last_viewed_date_to": { + "description": "Filter by `last_viewed_at < last_viewed_date_to`. Accepts absolute or relative dates.", + "type": "string" + }, + "limit": { + "description": "Number of results to return per page.", + "type": "number" + }, + "offset": { + "description": "The initial index from which to return the results.", + "type": "number" + }, + "order": { + "description": "Sort by an insight field, with a leading minus for descending order. Supports last_modified_at and last_viewed_at.", + "type": "string" + }, + "saved": { + "description": "When truthy, restricts results to insights that are saved (or attached to a visible dashboard). When falsy, only unsaved insights.", + "type": "boolean" + }, + "search": { + "description": "Search term matched across name, derived_name, description, and tag names. Returns exact (case-insensitive substring) matches only; if no exact match exists, returns similar (fuzzy trigram) matches instead. Each result's `search_match_type` is `exact` or `similar`.", + "type": "string" + }, + "short_id": { + "type": "string" + }, + "tags": { + "description": "JSON-encoded array of tag names. Returns insights with any of the listed tags.", + "type": "string" + }, + "user": { + "description": "Include this parameter (any value) to restrict results to insights created by the authenticated user.", + "type": "boolean" + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "insight-get", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "Numeric primary key or 8-character `short_id` (for example `AaVQ8Ijw`) identifying the insight." + }, + "filters_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's filters for this request only (not persisted). Top-level keys replace; nested values are not deep-merged — pass the complete value for any key you override. Accepts the same keys as the dashboard filters schema (e.g., `date_from`, `date_to`, `properties`). Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "variables_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's HogQL variables for this request only (not persisted). Format: {\"\": {\"code_name\": \"\", \"variableId\": \"\", \"value\": }}. Each entry must include `code_name` — partial entries are silently dropped. The simplest workflow is to call `insight-get` first, copy the matching entry from the response, and mutate `value`. Top-level keys replace; nested values are not deep-merged. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "id", + "context", + "llm_model" + ] + } + }, + { + "name": "insight-query", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "insightId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The insight to run: its numeric `id` or 8-character `short_id`." + }, + "output_format": { + "default": "optimized", + "description": "Output format. \"optimized\" returns a human-readable summary from server-side formatters (recommended for analysis). \"json\" returns the raw query results as JSON.", + "type": "string", + "enum": [ + "optimized", + "json" + ] + }, + "variables_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's HogQL variables for this run only (not persisted). Format: {\"\": {\"code_name\": \"\", \"variableId\": \"\", \"value\": }}. Each entry must include `code_name` — partial entries are silently dropped. The simplest workflow is to call `insight-get` first, copy the matching entry from the response's query variables, and mutate `value`. Top-level keys replace; nested values are not deep-merged. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "filters_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's filters for this run only (not persisted). Top-level keys replace; nested values are not deep-merged — pass the complete value for any key you override. Accepted keys: `date_from`, `date_to`, `explicitDate`, `interval`, `properties`, `filterTestAccounts`, `breakdown_filter`, `compareFilter`, `metricFilters`, `metricVariables`. Other keys (for example `display`, `dateRange`, `filter_test_accounts` or `compare`) are rejected. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "insightId", + "context", + "llm_model" + ] + } + }, + { + "name": "dashboards-get-all", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "created_by": { + "description": "Optional. Return only dashboards created by the user with this UUID.", + "type": "string" + }, + "exclude_generated": { + "description": "Optional. Exclude dashboards that PostHog generated.", + "type": "boolean" + }, + "folder": { + "description": "Optional. Return only dashboards filed directly in this project-tree folder, e.g. 'Unfiled/Dashboards'. An empty string matches dashboards at the project root. Nested sub-folders are not included.", + "type": "string" + }, + "limit": { + "description": "Number of results to return per page.", + "type": "number" + }, + "offset": { + "description": "The initial index from which to return the results.", + "type": "number" + }, + "ordering": { + "description": "Optional. `-last_viewed_at` puts the dashboards you viewed most recently first. A dashboard you never viewed sorts by its creation time. This order replaces the search relevance order.", + "type": "string", + "enum": [ + "-last_viewed_at" + ] + }, + "pinned": { + "description": "Optional. Return only pinned dashboards.", + "type": "boolean" + }, + "search": { + "description": "Optional. Match against dashboard `name`, `description`, and tag names. Returns exact (case-insensitive substring) matches only; if no exact match exists, returns similar (fuzzy trigram — typos, transpositions, prefix-as-you-type) matches instead. Results are then ordered by relevance, then pinned status, then name; each result's `search_match_type` is `exact` or `similar`. When omitted, dashboards are ordered by pinned status then alphabetical name. Capped at 200 characters; longer queries return a 400 error.", + "type": "string" + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "dashboard-get", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "A unique integer value identifying this dashboard." + }, + "filters_override": { + "description": "Object (or pre-encoded JSON string) to override dashboard filters for this request only (not persisted). Top-level keys replace; nested values are not deep-merged — pass the complete value for any key you override. Accepts the same keys as the dashboard filters schema (e.g., `date_from`, `date_to`, `properties`). Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "variables_override": { + "description": "Object (or pre-encoded JSON string) to override dashboard variables for this request only (not persisted). Format: {\"\": {\"code_name\": \"\", \"variableId\": \"\", \"value\": }}. Each entry must include `code_name` — partial entries are silently dropped. The simplest workflow is to call `dashboard-get` first, copy the matching entry from the response, and mutate `value`. Top-level keys replace; nested values are not deep-merged. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "id", + "context", + "llm_model" + ] + } + }, + { + "name": "feature-flag-get-all", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "active": { + "description": "Filter by flag state. `\"true\"` returns enabled flags, `\"false\"` returns disabled flags, and `\"STALE\"` returns enabled flags that PostHog classifies as stale.", + "type": "string", + "enum": [ + "STALE", + "false", + "true" + ] + }, + "archived": { + "description": "Filter by archived state. When omitted, archived flags are excluded.", + "type": "string", + "enum": [ + "false", + "true" + ] + }, + "created_by_id": { + "description": "Filter by the user(s) who created the feature flag. Accepts a single user ID, or a JSON-encoded / comma-separated list of user IDs to match any of them.", + "type": "string" + }, + "eligible_for_experiment": { + "description": "When 'true', only return flags that can back an experiment: multivariate with 2-20 variants. Any other value is ignored.", + "type": "string", + "enum": [ + "true" + ] + }, + "evaluation_runtime": { + "description": "Filter feature flags by their evaluation runtime.", + "type": "string", + "enum": [ + "all", + "client", + "server" + ] + }, + "excluded_properties": { + "description": "JSON-encoded list of feature flag keys to exclude from the results.", + "type": "string" + }, + "excluded_tags": { + "description": "JSON-encoded list of tag names to exclude. Flags carrying any of these tags are filtered out.", + "type": "string" + }, + "has_evaluation_contexts": { + "description": "Filter feature flags by presence of evaluation contexts. 'true' returns only flags with at least one evaluation context, 'false' returns only flags without.", + "type": "string", + "enum": [ + "false", + "true" + ] + }, + "key": { + "description": "Filter by exact feature flag key match. Case insensitive.", + "type": "string" + }, + "limit": { + "description": "Number of results to return per page.", + "type": "number" + }, + "offset": { + "description": "The initial index from which to return the results.", + "type": "number" + }, + "search": { + "description": "Search by feature flag key or name (case-insensitive). Use this to find the flag ID for get/update/delete tools.", + "type": "string" + }, + "tags": { + "description": "JSON-encoded list of tag names to filter feature flags by.", + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "boolean", + "experiment", + "multivariant", + "remote_config" + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "feature-flag-get-definition-by-key", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The feature flag key: the string identifier used in code (e.g. \"new-checkout\")." + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "key", + "context", + "llm_model" + ] + } + }, + { + "name": "dashboard-create", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 400 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "pinned": { + "type": "boolean" + }, + "breakdown_colors": { + "description": "Colors pinned to specific breakdown values across the dashboard's tiles. A list of entries, not an object keyed by breakdown value. Send an empty list to clear them.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "breakdownValue": { + "type": "string", + "description": "The breakdown value this color applies to, as it appears in the chart legend." + }, + "colorToken": { + "anyOf": [ + { + "type": "string", + "pattern": "^preset-[1-9][0-9]*$" + }, + { + "type": "null" + } + ], + "description": "Palette slot to color the value with, as `preset-1` upwards. Not a CSS color: a hex value is rejected. Null leaves the value on its default color." + }, + "breakdownType": { + "description": "Breakdown type the value came from, such as `event`, `person`, `session`, or `cohort`.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "breakdownProperty": { + "description": "Breakdown property the color is scoped to, so the color applies only to tiles that break down by that property. Omit to apply it under every property.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "description": "`manual` for a color a person picked, `auto` for one the dashboard assigned.\n\n* `auto` - auto\n* `manual` - manual", + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "* `auto` - auto\n* `manual` - manual" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "breakdownValue", + "colorToken" + ] + } + }, + { + "type": "null" + } + ] + }, + "data_color_theme_id": { + "description": "ID of the color theme used for chart visualizations.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "tags": { + "type": "array", + "items": {} + }, + "quick_filter_ids": { + "description": "List of quick filter IDs associated with this dashboard", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "grid_spacing": { + "description": "Named tile density preset. Use tight, condensed, standard, relaxed, or wide.\n\n* `tight` - tight\n* `condensed` - condensed\n* `standard` - standard\n* `relaxed` - relaxed\n* `wide` - wide", + "type": "string", + "enum": [ + "tight", + "condensed", + "standard", + "relaxed", + "wide" + ] + }, + "layout_compaction": { + "description": "How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n* `vertical` - vertical\n* `horizontal` - horizontal\n* `stable` - stable", + "type": "string", + "enum": [ + "vertical", + "horizontal", + "stable" + ] + }, + "group_titles": { + "description": "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "maxLength": 200 + } + }, + { + "type": "null" + } + ] + }, + "use_template": { + "description": "Template key to create the dashboard from a predefined template.", + "type": "string" + }, + "use_dashboard": { + "description": "ID of an existing dashboard to duplicate.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "delete_insights": { + "default": false, + "description": "When deleting, also delete insights that are only on this dashboard.", + "type": "boolean" + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "create-feature-flag", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "Feature flag key.", + "type": "string" + }, + "name": { + "description": "Feature flag description (stored in the `name` field for backwards compatibility).", + "type": "string" + }, + "filters": { + "description": "Feature flag targeting configuration.", + "type": "object", + "properties": { + "groups": { + "description": "Release condition groups for the feature flag.", + "type": "array", + "items": { + "type": "object", + "properties": { + "properties": { + "description": "Property conditions for this release condition group.", + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "value": { + "description": "Comparison value for the property filter. Supports strings, numbers, booleans, and arrays." + }, + "operator": { + "type": "string", + "enum": [ + "exact", + "is_not", + "icontains", + "not_icontains", + "starts_with", + "not_starts_with", + "ends_with", + "not_ends_with", + "regex", + "not_regex", + "gt", + "gte", + "lt", + "lte" + ], + "description": "Operator used to compare the property value.\n\n* `exact` - exact\n* `is_not` - is_not\n* `icontains` - icontains\n* `not_icontains` - not_icontains\n* `starts_with` - starts_with\n* `not_starts_with` - not_starts_with\n* `ends_with` - ends_with\n* `not_ends_with` - not_ends_with\n* `regex` - regex\n* `not_regex` - not_regex\n* `gt` - gt\n* `gte` - gte\n* `lt` - lt\n* `lte` - lte" + } + }, + "required": [ + "key", + "value", + "operator" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "is_set", + "is_not_set" + ], + "description": "Existence operator.\n\n* `is_set` - is_set\n* `is_not_set` - is_not_set" + }, + "value": { + "description": "Optional value. Runtime behavior determines whether this is ignored." + } + }, + "required": [ + "key", + "operator" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "is_date_exact", + "is_date_before", + "is_date_after" + ], + "description": "Date comparison operator.\n\n* `is_date_exact` - is_date_exact\n* `is_date_after` - is_date_after\n* `is_date_before` - is_date_before" + }, + "value": { + "type": "string", + "description": "Date value in ISO format or relative date expression." + } + }, + "required": [ + "key", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "semver_gt", + "semver_gte", + "semver_lt", + "semver_lte", + "semver_eq", + "semver_neq", + "semver_tilde", + "semver_caret", + "semver_wildcard" + ], + "description": "Semantic version comparison operator.\n\n* `semver_gt` - semver_gt\n* `semver_gte` - semver_gte\n* `semver_lt` - semver_lt\n* `semver_lte` - semver_lte\n* `semver_eq` - semver_eq\n* `semver_neq` - semver_neq\n* `semver_tilde` - semver_tilde\n* `semver_caret` - semver_caret\n* `semver_wildcard` - semver_wildcard" + }, + "value": { + "type": "string", + "description": "Semantic version string." + } + }, + "required": [ + "key", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "icontains_multi", + "not_icontains_multi" + ], + "description": "Multi-contains operator.\n\n* `icontains_multi` - icontains_multi\n* `not_icontains_multi` - not_icontains_multi" + }, + "value": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of strings to evaluate against." + } + }, + "required": [ + "key", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "type": "string", + "enum": [ + "cohort" + ], + "description": "Cohort property type required for in/not_in operators.\n\n* `cohort` - cohort" + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "in", + "not_in" + ], + "description": "Membership operator for cohort properties.\n\n* `in` - in\n* `not_in` - not_in" + }, + "value": { + "description": "Cohort comparison value (single or list, depending on usage)." + } + }, + "required": [ + "key", + "type", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "type": "string", + "enum": [ + "flag" + ], + "description": "Flag property type required for flag dependency checks.\n\n* `flag` - flag" + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "flag_evaluates_to" + ], + "description": "Operator for feature flag dependency evaluation.\n\n* `flag_evaluates_to` - flag_evaluates_to" + }, + "value": { + "description": "Value to compare flag evaluation against." + } + }, + "required": [ + "key", + "type", + "operator", + "value" + ] + } + ] + } + }, + "rollout_percentage": { + "description": "Rollout percentage for this release condition group.", + "type": "number" + }, + "variant": { + "description": "Variant key override for multivariate flags.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "aggregation_group_type_index": { + "description": "Group type index for this condition set. None means person-level aggregation.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + } + } + }, + "multivariate": { + "description": "Multivariate configuration for variant-based rollouts.", + "anyOf": [ + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Unique key for this variant." + }, + "name": { + "description": "Human-readable name for this variant.", + "type": "string" + }, + "rollout_percentage": { + "type": "number", + "description": "Variant rollout percentage." + } + }, + "required": [ + "key", + "rollout_percentage" + ] + }, + "description": "Variant definitions for multivariate feature flags." + } + }, + "required": [ + "variants" + ] + }, + { + "type": "null" + } + ] + }, + "aggregation_group_type_index": { + "description": "Group type index for group-based feature flags.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "payloads": { + "description": "Optional payload values keyed by variant key.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "feature_enrollment": { + "description": "Whether this flag has early access feature enrollment enabled. When true, the flag is evaluated against the person property $feature_enrollment/{flag_key}.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "early_exit": { + "default": false, + "description": "When true, condition evaluation stops at the first matching condition set rather than continuing to evaluate subsequent groups.", + "type": "boolean" + } + } + }, + "active": { + "description": "Whether the feature flag is active.", + "type": "boolean" + }, + "tags": { + "description": "Organizational tags for this feature flag.", + "type": "array", + "items": { + "type": "string" + } + }, + "evaluation_contexts": { + "description": "Evaluation contexts that control where this flag evaluates at runtime.", + "type": "array", + "items": { + "type": "string" + } + }, + "is_remote_configuration": { + "description": "Whether this flag delivers a payload instead of gating a feature (Remote Config mode). When true, set the delivered payload through the `filters` param under `filters.payloads.true` as a JSON-encoded string. There is no dedicated payload parameter.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ensure_experience_continuity": { + "description": "Whether to persist the flag's value for a user across the anonymous-to-identified transition (the \"persist across authentication steps\" option in the UI). Keeps a user's evaluated value stable once they log in. Incompatible with `device_id` bucketing.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "evaluation_runtime": { + "description": "Where this flag is allowed to evaluate — `server` (server-side SDKs only), `client` (client-side SDKs only), or `all` (both). Defaults to `all`.", + "anyOf": [ + { + "type": "string", + "enum": [ + "server", + "client", + "all" + ], + "description": "* `server` - Server\n* `client` - Client\n* `all` - All" + }, + { + "type": "null" + } + ] + }, + "bucketing_identifier": { + "description": "Identifier used to bucket users into rollout percentages and variants — `distinct_id` (user ID, the default) or `device_id`. Using `device_id` is incompatible with `ensure_experience_continuity=true`.", + "anyOf": [ + { + "type": "string", + "enum": [ + "distinct_id", + "device_id" + ], + "description": "* `distinct_id` - User ID (default)\n* `device_id` - Device ID" + }, + { + "type": "null" + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" — never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + } + ] +} diff --git a/backend/__tests__/unit/integrations/posthogHostedMcp.test.js b/backend/__tests__/unit/integrations/posthogHostedMcp.test.js new file mode 100644 index 0000000000..efbce2f9ff --- /dev/null +++ b/backend/__tests__/unit/integrations/posthogHostedMcp.test.js @@ -0,0 +1,37 @@ +const { POSTHOG_ENTRY } = require('../../../integrations/hostedMcp/posthog'); +const { HOSTED_MCP_ENTRIES } = require('../../../integrations/hostedMcp/entries'); +const { assessEntryTools, canonicalJson } = require('../../../services/hostedMcpEntryService'); +const capture = require('../../fixtures/hostedMcp/posthog-selected-tools-2026-10-21.json'); + +test('PostHog registers only the selected native tools from the consenting tools-mode capture', () => { + expect(HOSTED_MCP_ENTRIES).toContain(POSTHOG_ENTRY); + expect(capture.server).toBe(POSTHOG_ENTRY.resource); + expect(POSTHOG_ENTRY.audience).toBe('https://mcp.posthog.com/mcp'); + expect(capture.toolCountInFullCapture).toBe(780); + expect(capture.requestedScope).toBeNull(); // Scope narrowing needs its own consent check. + expect(POSTHOG_ENTRY.tools.map((tool) => tool.upstreamName)).toEqual(capture.tools.map((tool) => tool.name)); + expect(POSTHOG_ENTRY.tools).toHaveLength(9); + expect(POSTHOG_ENTRY.tools.filter((tool) => tool.class === 'read')).toHaveLength(7); + expect(POSTHOG_ENTRY.tools.filter((tool) => tool.class === 'write')).toHaveLength(2); + expect(POSTHOG_ENTRY.tools.some((tool) => tool.name === 'exec' || tool.name === 'execute-sql')).toBe(false); + + for (const [index, pin] of POSTHOG_ENTRY.tools.entries()) { + const measured = capture.tools[index]; + expect(canonicalJson(pin.inputSchema)).toBe(canonicalJson(measured.inputSchema)); + expect(pin.annotations).toEqual({ + readOnlyHint: measured.annotations.readOnlyHint, + destructiveHint: measured.annotations.destructiveHint, + }); + expect(pin.class).toBe(measured.annotations.readOnlyHint ? 'read' : 'write'); + } + expect(assessEntryTools(POSTHOG_ENTRY, capture.tools).every((result) => result.verdict === 'ok')).toBe(true); +}); + +test('a moved PostHog write schema or withdrawn read claim withholds that pin', () => { + const changed = structuredClone(capture.tools); + changed.find((tool) => tool.name === 'dashboard-create').inputSchema.required = ['name', 'unknown']; + changed.find((tool) => tool.name === 'insights-list').annotations.readOnlyHint = false; + const verdicts = new Map(assessEntryTools(POSTHOG_ENTRY, changed).map((item) => [item.name, item.verdict])); + expect(verdicts.get('dashboard-create')).toBe('tool_drift'); + expect(verdicts.get('insights-list')).toBe('tool_drift'); +}); diff --git a/backend/__tests__/unit/services/hostedMcpEntry.test.js b/backend/__tests__/unit/services/hostedMcpEntry.test.js index b6fcb64c46..c545ca3432 100644 --- a/backend/__tests__/unit/services/hostedMcpEntry.test.js +++ b/backend/__tests__/unit/services/hostedMcpEntry.test.js @@ -567,7 +567,9 @@ describe('the shipped catalogue cannot land half-wired', () => { test('ships Linear, Google Calendar, Atlassian, then Airtable, and lookup names the requested entry', () => { // The catalogue is the one place that says what an instance offers before // any member has connected anything. Its ordering follows the build plan. - expect(HOSTED_MCP_ENTRIES.map((entry) => entry.id)).toEqual(['linear', 'google-calendar', 'atlassian', 'airtable']); + expect(HOSTED_MCP_ENTRIES.map((entry) => entry.id)).toEqual([ + 'linear', 'google-calendar', 'atlassian', 'airtable', 'posthog', + ]); const catalogue = [linear(), linear({ id: 'notion', title: 'Notion' })]; expect(findHostedMcpEntry(catalogue, 'notion').title).toBe('Notion'); expect(findHostedMcpEntry(catalogue, 'linear').title).toBe('Linear'); diff --git a/backend/__tests__/unit/services/hostedMcpToolDefinitions.test.js b/backend/__tests__/unit/services/hostedMcpToolDefinitions.test.js index c6f2524ae1..228f987f74 100644 --- a/backend/__tests__/unit/services/hostedMcpToolDefinitions.test.js +++ b/backend/__tests__/unit/services/hostedMcpToolDefinitions.test.js @@ -11,6 +11,7 @@ */ const { HOSTED_MCP_CALL_TIMEOUT_MS, + HOSTED_MCP_LIST_MAX_BYTES, createHostedMcpRuntimeDriftCache, hostedToolDefinitions, findHostedToolDefinition, @@ -137,6 +138,42 @@ describe('hosted-MCP tool definitions', () => { expect(JSON.parse(deps.fetcher.mock.calls[0][1].body).method).toBe('tools/list'); }); + it('refuses an oversized JSON tools/list body before parsing or calling a tool', async () => { + const reply = JSON.stringify({ jsonrpc: '2.0', id: 'list-1', result: { tools: [{ + name: 'list_issues', inputSchema: ENTRY.tools[0].inputSchema, + }] } }); + const body = new TextEncoder().encode(reply + ' '.repeat(HOSTED_MCP_LIST_MAX_BYTES)); + const deps = makeDeps({ + fetcher: jest.fn(async () => new Response(body, { + headers: { 'content-type': 'application/json' }, + })), + }); + deps.driftCache = createHostedMcpRuntimeDriftCache(deps, [ENTRY]); + await expect(definitionFor('linear.list_issues', deps).call({}, CONNECTION)) + .rejects.toMatchObject({ code: 'provider_error', message: expect.stringContaining('could not be verified') }); + expect(deps.fetcher).toHaveBeenCalledTimes(1); + }); + + it('shares the byte budget across SSE tools/list pages', async () => { + const first = `data: ${JSON.stringify({ + jsonrpc: '2.0', id: 'list-1', result: { tools: [], nextCursor: 'page-2' }, + })}\n${' '.repeat(Math.floor(HOSTED_MCP_LIST_MAX_BYTES / 2))}`; + const second = `data: ${JSON.stringify({ + jsonrpc: '2.0', id: 'list-2', result: { tools: [{ + name: 'list_issues', inputSchema: ENTRY.tools[0].inputSchema, + }] }, + })}\n${' '.repeat(Math.floor(HOSTED_MCP_LIST_MAX_BYTES / 2))}`; + const deps = makeDeps({ + fetcher: jest.fn(async (_url, init) => sseResponse([ + JSON.parse(init.body).params.cursor ? second : first, + ])), + }); + deps.driftCache = createHostedMcpRuntimeDriftCache(deps, [ENTRY]); + await expect(definitionFor('linear.list_issues', deps).call({}, CONNECTION)) + .rejects.toMatchObject({ code: 'provider_error', message: expect.stringContaining('could not be verified') }); + expect(deps.fetcher).toHaveBeenCalledTimes(2); + }); + it('offers nothing when the catalogue is empty, and one definition per pinned tool otherwise', () => { expect(hostedToolDefinitions([], makeDeps())).toHaveLength(0); const definitions = hostedToolDefinitions([ENTRY], makeDeps()); diff --git a/backend/integrations/hostedMcp/entries.ts b/backend/integrations/hostedMcp/entries.ts index 8d70c2f40d..d8b0c7c645 100644 --- a/backend/integrations/hostedMcp/entries.ts +++ b/backend/integrations/hostedMcp/entries.ts @@ -16,8 +16,11 @@ import { AIRTABLE_ENTRY } from './airtable'; import { ATLASSIAN_ENTRY } from './atlassian'; import { GOOGLE_CALENDAR_ENTRY } from './googleCalendar'; import { LINEAR_ENTRY } from './linear'; +import { POSTHOG_ENTRY } from './posthog'; -export const HOSTED_MCP_ENTRIES: HostedMcpEntry[] = [LINEAR_ENTRY, GOOGLE_CALENDAR_ENTRY, ATLASSIAN_ENTRY, AIRTABLE_ENTRY]; +export const HOSTED_MCP_ENTRIES: HostedMcpEntry[] = [ + LINEAR_ENTRY, GOOGLE_CALENDAR_ENTRY, ATLASSIAN_ENTRY, AIRTABLE_ENTRY, POSTHOG_ENTRY, +]; /** * Seven entry-local defects that would otherwise be silent, checked at module diff --git a/backend/integrations/hostedMcp/posthog-pins.json b/backend/integrations/hostedMcp/posthog-pins.json new file mode 100644 index 0000000000..18af29516d --- /dev/null +++ b/backend/integrations/hostedMcp/posthog-pins.json @@ -0,0 +1,1431 @@ +{ + "tools": [ + { + "name": "insights-list", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "created_by": { + "description": "JSON-encoded array of user IDs. Only returns insights whose `created_by` is in the list, e.g. `[1,42]`.", + "type": "string" + }, + "created_date_from": { + "description": "Filter by `created_at > created_date_from`. Accepts absolute or relative dates.", + "type": "string" + }, + "created_date_to": { + "description": "Filter by `created_at < created_date_to`. Accepts absolute or relative dates.", + "type": "string" + }, + "dashboards": { + "description": "JSON-encoded array of dashboard IDs. Returns insights attached to every listed dashboard (AND).", + "type": "string" + }, + "date_from": { + "description": "Filter by `last_modified_at > date_from`. Accepts absolute dates (`2025-04-23`) or relative strings (`-7d`, `-1m`).", + "type": "string" + }, + "date_to": { + "description": "Filter by `last_modified_at < date_to`. Accepts absolute dates or relative strings.", + "type": "string" + }, + "display": { + "description": "Comma-separated chart types, e.g. `ActionsLineGraph,ActionsBar`. Only returns trends and SQL insights shown as one of them. A trends insight with no chart type set counts as `ActionsLineGraph`.", + "type": "string" + }, + "exclude_bi": { + "description": "Exclude Business intelligence worksheets from the insight list.", + "type": "boolean" + }, + "favorited": { + "description": "Include this parameter (any value) to restrict results to insights marked as favorited.", + "type": "boolean" + }, + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "insight": { + "description": "Restrict to a single insight type. `JSON` matches non-wrapper query insights; `SQL` matches HogQL queries; `BI` matches editable worksheets.", + "type": "string", + "enum": [ + "BI", + "FUNNELS", + "JOURNEYS", + "JSON", + "LIFECYCLE", + "PATHS", + "RETENTION", + "SQL", + "STICKINESS", + "TRENDS" + ] + }, + "last_viewed_date_from": { + "description": "Filter by `last_viewed_at > last_viewed_date_from`. Accepts absolute or relative dates.", + "type": "string" + }, + "last_viewed_date_to": { + "description": "Filter by `last_viewed_at < last_viewed_date_to`. Accepts absolute or relative dates.", + "type": "string" + }, + "limit": { + "description": "Number of results to return per page.", + "type": "number" + }, + "offset": { + "description": "The initial index from which to return the results.", + "type": "number" + }, + "order": { + "description": "Sort by an insight field, with a leading minus for descending order. Supports last_modified_at and last_viewed_at.", + "type": "string" + }, + "saved": { + "description": "When truthy, restricts results to insights that are saved (or attached to a visible dashboard). When falsy, only unsaved insights.", + "type": "boolean" + }, + "search": { + "description": "Search term matched across name, derived_name, description, and tag names. Returns exact (case-insensitive substring) matches only; if no exact match exists, returns similar (fuzzy trigram) matches instead. Each result's `search_match_type` is `exact` or `similar`.", + "type": "string" + }, + "short_id": { + "type": "string" + }, + "tags": { + "description": "JSON-encoded array of tag names. Returns insights with any of the listed tags.", + "type": "string" + }, + "user": { + "description": "Include this parameter (any value) to restrict results to insights created by the authenticated user.", + "type": "boolean" + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "insight-get", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "Numeric primary key or 8-character `short_id` (for example `AaVQ8Ijw`) identifying the insight." + }, + "filters_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's filters for this request only (not persisted). Top-level keys replace; nested values are not deep-merged \u2014 pass the complete value for any key you override. Accepts the same keys as the dashboard filters schema (e.g., `date_from`, `date_to`, `properties`). Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "variables_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's HogQL variables for this request only (not persisted). Format: {\"\": {\"code_name\": \"\", \"variableId\": \"\", \"value\": }}. Each entry must include `code_name` \u2014 partial entries are silently dropped. The simplest workflow is to call `insight-get` first, copy the matching entry from the response, and mutate `value`. Top-level keys replace; nested values are not deep-merged. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "id", + "context", + "llm_model" + ] + } + }, + { + "name": "insight-query", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "insightId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The insight to run: its numeric `id` or 8-character `short_id`." + }, + "output_format": { + "default": "optimized", + "description": "Output format. \"optimized\" returns a human-readable summary from server-side formatters (recommended for analysis). \"json\" returns the raw query results as JSON.", + "type": "string", + "enum": [ + "optimized", + "json" + ] + }, + "variables_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's HogQL variables for this run only (not persisted). Format: {\"\": {\"code_name\": \"\", \"variableId\": \"\", \"value\": }}. Each entry must include `code_name` \u2014 partial entries are silently dropped. The simplest workflow is to call `insight-get` first, copy the matching entry from the response's query variables, and mutate `value`. Top-level keys replace; nested values are not deep-merged. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "filters_override": { + "description": "Object (or pre-encoded JSON string) to override the insight's filters for this run only (not persisted). Top-level keys replace; nested values are not deep-merged \u2014 pass the complete value for any key you override. Accepted keys: `date_from`, `date_to`, `explicitDate`, `interval`, `properties`, `filterTestAccounts`, `breakdown_filter`, `compareFilter`, `metricFilters`, `metricVariables`. Other keys (for example `display`, `dateRange`, `filter_test_accounts` or `compare`) are rejected. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "insightId", + "context", + "llm_model" + ] + } + }, + { + "name": "dashboards-get-all", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "created_by": { + "description": "Optional. Return only dashboards created by the user with this UUID.", + "type": "string" + }, + "exclude_generated": { + "description": "Optional. Exclude dashboards that PostHog generated.", + "type": "boolean" + }, + "folder": { + "description": "Optional. Return only dashboards filed directly in this project-tree folder, e.g. 'Unfiled/Dashboards'. An empty string matches dashboards at the project root. Nested sub-folders are not included.", + "type": "string" + }, + "limit": { + "description": "Number of results to return per page.", + "type": "number" + }, + "offset": { + "description": "The initial index from which to return the results.", + "type": "number" + }, + "ordering": { + "description": "Optional. `-last_viewed_at` puts the dashboards you viewed most recently first. A dashboard you never viewed sorts by its creation time. This order replaces the search relevance order.", + "type": "string", + "enum": [ + "-last_viewed_at" + ] + }, + "pinned": { + "description": "Optional. Return only pinned dashboards.", + "type": "boolean" + }, + "search": { + "description": "Optional. Match against dashboard `name`, `description`, and tag names. Returns exact (case-insensitive substring) matches only; if no exact match exists, returns similar (fuzzy trigram \u2014 typos, transpositions, prefix-as-you-type) matches instead. Results are then ordered by relevance, then pinned status, then name; each result's `search_match_type` is `exact` or `similar`. When omitted, dashboards are ordered by pinned status then alphabetical name. Capped at 200 characters; longer queries return a 400 error.", + "type": "string" + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "dashboard-get", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "A unique integer value identifying this dashboard." + }, + "filters_override": { + "description": "Object (or pre-encoded JSON string) to override dashboard filters for this request only (not persisted). Top-level keys replace; nested values are not deep-merged \u2014 pass the complete value for any key you override. Accepts the same keys as the dashboard filters schema (e.g., `date_from`, `date_to`, `properties`). Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "variables_override": { + "description": "Object (or pre-encoded JSON string) to override dashboard variables for this request only (not persisted). Format: {\"\": {\"code_name\": \"\", \"variableId\": \"\", \"value\": }}. Each entry must include `code_name` \u2014 partial entries are silently dropped. The simplest workflow is to call `dashboard-get` first, copy the matching entry from the response, and mutate `value`. Top-level keys replace; nested values are not deep-merged. Ignored when accessed via a sharing token.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "id", + "context", + "llm_model" + ] + } + }, + { + "name": "feature-flag-get-all", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "active": { + "description": "Filter by flag state. `\"true\"` returns enabled flags, `\"false\"` returns disabled flags, and `\"STALE\"` returns enabled flags that PostHog classifies as stale.", + "type": "string", + "enum": [ + "STALE", + "false", + "true" + ] + }, + "archived": { + "description": "Filter by archived state. When omitted, archived flags are excluded.", + "type": "string", + "enum": [ + "false", + "true" + ] + }, + "created_by_id": { + "description": "Filter by the user(s) who created the feature flag. Accepts a single user ID, or a JSON-encoded / comma-separated list of user IDs to match any of them.", + "type": "string" + }, + "eligible_for_experiment": { + "description": "When 'true', only return flags that can back an experiment: multivariate with 2-20 variants. Any other value is ignored.", + "type": "string", + "enum": [ + "true" + ] + }, + "evaluation_runtime": { + "description": "Filter feature flags by their evaluation runtime.", + "type": "string", + "enum": [ + "all", + "client", + "server" + ] + }, + "excluded_properties": { + "description": "JSON-encoded list of feature flag keys to exclude from the results.", + "type": "string" + }, + "excluded_tags": { + "description": "JSON-encoded list of tag names to exclude. Flags carrying any of these tags are filtered out.", + "type": "string" + }, + "has_evaluation_contexts": { + "description": "Filter feature flags by presence of evaluation contexts. 'true' returns only flags with at least one evaluation context, 'false' returns only flags without.", + "type": "string", + "enum": [ + "false", + "true" + ] + }, + "key": { + "description": "Filter by exact feature flag key match. Case insensitive.", + "type": "string" + }, + "limit": { + "description": "Number of results to return per page.", + "type": "number" + }, + "offset": { + "description": "The initial index from which to return the results.", + "type": "number" + }, + "search": { + "description": "Search by feature flag key or name (case-insensitive). Use this to find the flag ID for get/update/delete tools.", + "type": "string" + }, + "tags": { + "description": "JSON-encoded list of tag names to filter feature flags by.", + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "boolean", + "experiment", + "multivariant", + "remote_config" + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "feature-flag-get-definition-by-key", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The feature flag key: the string identifier used in code (e.g. \"new-checkout\")." + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "key", + "context", + "llm_model" + ] + } + }, + { + "name": "dashboard-create", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "include_dashboards": { + "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", + "type": "boolean" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 400 + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "pinned": { + "type": "boolean" + }, + "breakdown_colors": { + "description": "Colors pinned to specific breakdown values across the dashboard's tiles. A list of entries, not an object keyed by breakdown value. Send an empty list to clear them.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "breakdownValue": { + "type": "string", + "description": "The breakdown value this color applies to, as it appears in the chart legend." + }, + "colorToken": { + "anyOf": [ + { + "type": "string", + "pattern": "^preset-[1-9][0-9]*$" + }, + { + "type": "null" + } + ], + "description": "Palette slot to color the value with, as `preset-1` upwards. Not a CSS color: a hex value is rejected. Null leaves the value on its default color." + }, + "breakdownType": { + "description": "Breakdown type the value came from, such as `event`, `person`, `session`, or `cohort`.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "breakdownProperty": { + "description": "Breakdown property the color is scoped to, so the color applies only to tiles that break down by that property. Omit to apply it under every property.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "description": "`manual` for a color a person picked, `auto` for one the dashboard assigned.\n\n* `auto` - auto\n* `manual` - manual", + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "manual" + ], + "description": "* `auto` - auto\n* `manual` - manual" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "breakdownValue", + "colorToken" + ] + } + }, + { + "type": "null" + } + ] + }, + "data_color_theme_id": { + "description": "ID of the color theme used for chart visualizations.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "tags": { + "type": "array", + "items": {} + }, + "quick_filter_ids": { + "description": "List of quick filter IDs associated with this dashboard", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "grid_spacing": { + "description": "Named tile density preset. Use tight, condensed, standard, relaxed, or wide.\n\n* `tight` - tight\n* `condensed` - condensed\n* `standard` - standard\n* `relaxed` - relaxed\n* `wide` - wide", + "type": "string", + "enum": [ + "tight", + "condensed", + "standard", + "relaxed", + "wide" + ] + }, + "layout_compaction": { + "description": "How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n* `vertical` - vertical\n* `horizontal` - horizontal\n* `stable` - stable", + "type": "string", + "enum": [ + "vertical", + "horizontal", + "stable" + ] + }, + "group_titles": { + "description": "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "maxLength": 200 + } + }, + { + "type": "null" + } + ] + }, + "use_template": { + "description": "Template key to create the dashboard from a predefined template.", + "type": "string" + }, + "use_dashboard": { + "description": "ID of an existing dashboard to duplicate.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "delete_insights": { + "default": false, + "description": "When deleting, also delete insights that are only on this dashboard.", + "type": "boolean" + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + }, + { + "name": "create-feature-flag", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "key": { + "description": "Feature flag key.", + "type": "string" + }, + "name": { + "description": "Feature flag description (stored in the `name` field for backwards compatibility).", + "type": "string" + }, + "filters": { + "description": "Feature flag targeting configuration.", + "type": "object", + "properties": { + "groups": { + "description": "Release condition groups for the feature flag.", + "type": "array", + "items": { + "type": "object", + "properties": { + "properties": { + "description": "Property conditions for this release condition group.", + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "value": { + "description": "Comparison value for the property filter. Supports strings, numbers, booleans, and arrays." + }, + "operator": { + "type": "string", + "enum": [ + "exact", + "is_not", + "icontains", + "not_icontains", + "starts_with", + "not_starts_with", + "ends_with", + "not_ends_with", + "regex", + "not_regex", + "gt", + "gte", + "lt", + "lte" + ], + "description": "Operator used to compare the property value.\n\n* `exact` - exact\n* `is_not` - is_not\n* `icontains` - icontains\n* `not_icontains` - not_icontains\n* `starts_with` - starts_with\n* `not_starts_with` - not_starts_with\n* `ends_with` - ends_with\n* `not_ends_with` - not_ends_with\n* `regex` - regex\n* `not_regex` - not_regex\n* `gt` - gt\n* `gte` - gte\n* `lt` - lt\n* `lte` - lte" + } + }, + "required": [ + "key", + "value", + "operator" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "is_set", + "is_not_set" + ], + "description": "Existence operator.\n\n* `is_set` - is_set\n* `is_not_set` - is_not_set" + }, + "value": { + "description": "Optional value. Runtime behavior determines whether this is ignored." + } + }, + "required": [ + "key", + "operator" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "is_date_exact", + "is_date_before", + "is_date_after" + ], + "description": "Date comparison operator.\n\n* `is_date_exact` - is_date_exact\n* `is_date_after` - is_date_after\n* `is_date_before` - is_date_before" + }, + "value": { + "type": "string", + "description": "Date value in ISO format or relative date expression." + } + }, + "required": [ + "key", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "semver_gt", + "semver_gte", + "semver_lt", + "semver_lte", + "semver_eq", + "semver_neq", + "semver_tilde", + "semver_caret", + "semver_wildcard" + ], + "description": "Semantic version comparison operator.\n\n* `semver_gt` - semver_gt\n* `semver_gte` - semver_gte\n* `semver_lt` - semver_lt\n* `semver_lte` - semver_lte\n* `semver_eq` - semver_eq\n* `semver_neq` - semver_neq\n* `semver_tilde` - semver_tilde\n* `semver_caret` - semver_caret\n* `semver_wildcard` - semver_wildcard" + }, + "value": { + "type": "string", + "description": "Semantic version string." + } + }, + "required": [ + "key", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "description": "Property filter type. Set it on every property. Use `group` with `group_type_index` to filter on a group's properties.\n\n* `cohort` - cohort\n* `person` - person\n* `group` - group", + "type": "string", + "enum": [ + "cohort", + "person", + "group" + ] + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "icontains_multi", + "not_icontains_multi" + ], + "description": "Multi-contains operator.\n\n* `icontains_multi` - icontains_multi\n* `not_icontains_multi` - not_icontains_multi" + }, + "value": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of strings to evaluate against." + } + }, + "required": [ + "key", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "type": "string", + "enum": [ + "cohort" + ], + "description": "Cohort property type required for in/not_in operators.\n\n* `cohort` - cohort" + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "in", + "not_in" + ], + "description": "Membership operator for cohort properties.\n\n* `in` - in\n* `not_in` - not_in" + }, + "value": { + "description": "Cohort comparison value (single or list, depending on usage)." + } + }, + "required": [ + "key", + "type", + "operator", + "value" + ] + }, + { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Property key used in this feature flag condition." + }, + "type": { + "type": "string", + "enum": [ + "flag" + ], + "description": "Flag property type required for flag dependency checks.\n\n* `flag` - flag" + }, + "cohort_name": { + "description": "Resolved cohort name for cohort-type filters.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "group_type_index": { + "description": "Group type index a `group` filter reads properties from. Defaults to the condition set's `aggregation_group_type_index`.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string", + "enum": [ + "flag_evaluates_to" + ], + "description": "Operator for feature flag dependency evaluation.\n\n* `flag_evaluates_to` - flag_evaluates_to" + }, + "value": { + "description": "Value to compare flag evaluation against." + } + }, + "required": [ + "key", + "type", + "operator", + "value" + ] + } + ] + } + }, + "rollout_percentage": { + "description": "Rollout percentage for this release condition group.", + "type": "number" + }, + "variant": { + "description": "Variant key override for multivariate flags.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "aggregation_group_type_index": { + "description": "Group type index for this condition set. None means person-level aggregation.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + } + } + }, + "multivariate": { + "description": "Multivariate configuration for variant-based rollouts.", + "anyOf": [ + { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Unique key for this variant." + }, + "name": { + "description": "Human-readable name for this variant.", + "type": "string" + }, + "rollout_percentage": { + "type": "number", + "description": "Variant rollout percentage." + } + }, + "required": [ + "key", + "rollout_percentage" + ] + }, + "description": "Variant definitions for multivariate feature flags." + } + }, + "required": [ + "variants" + ] + }, + { + "type": "null" + } + ] + }, + "aggregation_group_type_index": { + "description": "Group type index for group-based feature flags.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "payloads": { + "description": "Optional payload values keyed by variant key.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "feature_enrollment": { + "description": "Whether this flag has early access feature enrollment enabled. When true, the flag is evaluated against the person property $feature_enrollment/{flag_key}.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "early_exit": { + "default": false, + "description": "When true, condition evaluation stops at the first matching condition set rather than continuing to evaluate subsequent groups.", + "type": "boolean" + } + } + }, + "active": { + "description": "Whether the feature flag is active.", + "type": "boolean" + }, + "tags": { + "description": "Organizational tags for this feature flag.", + "type": "array", + "items": { + "type": "string" + } + }, + "evaluation_contexts": { + "description": "Evaluation contexts that control where this flag evaluates at runtime.", + "type": "array", + "items": { + "type": "string" + } + }, + "is_remote_configuration": { + "description": "Whether this flag delivers a payload instead of gating a feature (Remote Config mode). When true, set the delivered payload through the `filters` param under `filters.payloads.true` as a JSON-encoded string. There is no dedicated payload parameter.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "ensure_experience_continuity": { + "description": "Whether to persist the flag's value for a user across the anonymous-to-identified transition (the \"persist across authentication steps\" option in the UI). Keeps a user's evaluated value stable once they log in. Incompatible with `device_id` bucketing.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "evaluation_runtime": { + "description": "Where this flag is allowed to evaluate \u2014 `server` (server-side SDKs only), `client` (client-side SDKs only), or `all` (both). Defaults to `all`.", + "anyOf": [ + { + "type": "string", + "enum": [ + "server", + "client", + "all" + ], + "description": "* `server` - Server\n* `client` - Client\n* `all` - All" + }, + { + "type": "null" + } + ] + }, + "bucketing_identifier": { + "description": "Identifier used to bucket users into rollout percentages and variants \u2014 `distinct_id` (user ID, the default) or `device_id`. Using `device_id` is incompatible with `ensure_experience_continuity=true`.", + "anyOf": [ + { + "type": "string", + "enum": [ + "distinct_id", + "device_id" + ], + "description": "* `distinct_id` - User ID (default)\n* `device_id` - Device ID" + }, + { + "type": "null" + } + ] + }, + "context": { + "type": "string", + "description": "Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as \"a user\", \"the customer\", or \"an account\". Example: \"Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution.\"" + }, + "llm_model": { + "type": "string", + "description": "The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. \"claude-opus-4-8\", \"gpt-5.2\"). Used for analytics only. If you do not know your model identifier with certainty, pass \"unknown\" \u2014 never guess." + }, + "conversation_id": { + "type": "string", + "description": "Pass the exact conversation_id from the server's previous response, unchanged. The server provides it on the first call \u2014 never invent one, and do not issue parallel tool calls until you have it. Keep passing the same conversation_id for the rest of the conversation, including after later user messages or on a different task; do not reset it when the user starts a new request." + } + }, + "required": [ + "context", + "llm_model" + ] + } + } + ] +} diff --git a/backend/integrations/hostedMcp/posthog.ts b/backend/integrations/hostedMcp/posthog.ts new file mode 100644 index 0000000000..ab26a87183 --- /dev/null +++ b/backend/integrations/hostedMcp/posthog.ts @@ -0,0 +1,63 @@ +/** + * PostHog hosted MCP, native tools mode. The nine named pins are selected from + * connector-ops's consenting 2026-10-21 tools/list capture. The single `exec` + * gateway and third-party gateway routes are excluded. + * + * The capture was project-limited but granted 153 broad OAuth scopes. The + * smaller scope set below is a candidate inferred from corresponding PostHog + * API operations; a scoped consent and call probe must verify it before merge. + */ +import type { HostedMcpEntry, HostedMcpPinnedTool } from '../../services/hostedMcpEntryService'; +import captured from './posthog-pins.json'; + +const capturedByName = new Map(captured.tools.map((tool) => [tool.name, tool])); + +const pinnedTool = ( + name: string, + description: string, + toolClass: 'read' | 'write', +): HostedMcpPinnedTool => { + const measured = capturedByName.get(name); + if (!measured) throw new Error(`PostHog pin is absent from the consenting capture: ${name}`); + return { + name, + upstreamName: name, + description, + class: toolClass, + inputSchema: measured.inputSchema, + annotations: { + readOnlyHint: measured.annotations.readOnlyHint, + destructiveHint: measured.annotations.destructiveHint, + }, + }; +}; + +export const POSTHOG_ENTRY: HostedMcpEntry = { + id: 'posthog', + title: 'PostHog', + description: 'Insights, dashboards and feature flags in your PostHog project.', + resource: 'https://mcp.posthog.com/mcp?mode=tools', + // PRM advertises bare /mcp as the RFC 8707 audience; the query selects tools mode only. + audience: 'https://mcp.posthog.com/mcp', + issuer: 'https://oauth.posthog.com', + client: 'cimd', + scopes: [ + 'dashboard:read', 'dashboard:write', 'feature_flag:read', + 'feature_flag:write', 'insight:read', 'query:read', + ], + revoke: { + page: 'https://app.posthog.com/settings/user-connected-apps', + endpoint: 'https://oauth.posthog.com/oauth/revoke/', + }, + tools: [ + pinnedTool('insights-list', 'List saved insights in the connected PostHog project.', 'read'), + pinnedTool('insight-get', 'Read a saved insight definition by ID.', 'read'), + pinnedTool('insight-query', 'Run a saved insight and read its results.', 'read'), + pinnedTool('dashboards-get-all', 'List dashboards in the connected PostHog project.', 'read'), + pinnedTool('dashboard-get', 'Read a dashboard and its tiles by ID.', 'read'), + pinnedTool('feature-flag-get-all', 'List feature flag definitions in the connected project.', 'read'), + pinnedTool('feature-flag-get-definition-by-key', 'Read a feature flag definition by key.', 'read'), + pinnedTool('dashboard-create', 'Create a dashboard in the connected project.', 'write'), + pinnedTool('create-feature-flag', 'Create a feature flag in the connected project.', 'write'), + ], +}; diff --git a/backend/services/hostedMcpToolDefinitions.ts b/backend/services/hostedMcpToolDefinitions.ts index b753f5b2a6..caba66edde 100644 --- a/backend/services/hostedMcpToolDefinitions.ts +++ b/backend/services/hostedMcpToolDefinitions.ts @@ -41,6 +41,10 @@ import type { ToolConnection, ToolDefinition } from './toolBrokerService'; * rather than as a refusal, so the trail does not read as the member's fault. */ export const HOSTED_MCP_CALL_TIMEOUT_MS = 30 * 1000; +// The timeout and page limit do not bound a vendor's response body. PostHog's +// native tools/list is already over 5 MB; leave room for growth, but refuse an +// unbounded list before parsing or retaining its tools across pages. +export const HOSTED_MCP_LIST_MAX_BYTES = 16 * 1024 * 1024; export interface HostedMcpToolDeps { /** The row the connection id names. Absent means the grant outlived its row. */ @@ -87,19 +91,50 @@ const defaultDeps = (): HostedMcpToolDeps => ({ * `result` or an `error`: a frame that is neither is not the answer to this * call, so it is not mistaken for one. */ -const jsonRpcReply = async (response: Response): Promise> => { +const boundedResponseText = async (response: Response, budget: { remaining: number }): Promise => { + const reader = response.body?.getReader(); + if (!reader) return ''; + const decoder = new TextDecoder(); + const parts: string[] = []; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + budget.remaining -= value.byteLength; + if (budget.remaining < 0) { + await reader.cancel(); + throw new RoomGrantError('provider_error', 'hosted-MCP tools/list exceeded the response size limit', 502); + } + parts.push(decoder.decode(value, { stream: true })); + } + parts.push(decoder.decode()); + return parts.join(''); + } finally { + reader.releaseLock(); + } +}; + +const jsonRpcReply = async ( + response: Response, + listBudget?: { remaining: number }, +): Promise> => { const contentType = String(response.headers?.get?.('content-type') || '').toLowerCase(); if (!contentType.includes('text/event-stream')) { let body: Record; try { - body = await response.json() as Record; - } catch { + body = listBudget + ? JSON.parse(await boundedResponseText(response, listBudget)) as Record + : await response.json() as Record; + } catch (error) { + if (error instanceof RoomGrantError) throw error; throw new RoomGrantError('provider_error', 'hosted-MCP vendor returned invalid JSON', 502); } if (body && (body.result !== undefined || body.error !== undefined)) return body; throw new RoomGrantError('provider_error', 'hosted-MCP vendor returned no JSON-RPC reply', 502); } - const text = await response.text(); + const text = listBudget + ? await boundedResponseText(response, listBudget) + : await response.text(); for (const line of text.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; @@ -129,6 +164,7 @@ const listUpstreamTools = async ( const timer = setTimeout(() => controller.abort(), HOSTED_MCP_CALL_TIMEOUT_MS); try { const allTools: HostedMcpUpstreamTool[] = []; + const listBudget = { remaining: HOSTED_MCP_LIST_MAX_BYTES }; const seenCursors = new Set(); let cursor: string | undefined; for (let page = 0; page < 100; page += 1) { @@ -155,7 +191,7 @@ const listUpstreamTools = async ( if (!response.ok) { throw new RoomGrantError('provider_error', `hosted-MCP vendor answered ${response.status}`, 502); } - const reply = await jsonRpcReply(response); + const reply = await jsonRpcReply(response, listBudget); if (reply.error) throw new RoomGrantError('provider_error', 'hosted-MCP tools/list returned an error', 502); const result = reply.result as { tools?: unknown; nextCursor?: unknown } | undefined; if (!Array.isArray(result?.tools)) { diff --git a/frontend/src/v2/__tests__/platformGlyphCatalogue.test.tsx b/frontend/src/v2/__tests__/platformGlyphCatalogue.test.tsx index f9e59add45..6143ce698a 100644 --- a/frontend/src/v2/__tests__/platformGlyphCatalogue.test.tsx +++ b/frontend/src/v2/__tests__/platformGlyphCatalogue.test.tsx @@ -54,4 +54,13 @@ describe('platform glyph catalogue contract', () => { expect(svg?.querySelector('path')?.getAttribute('d')).toContain('M3.5 10h17'); expect(svg?.querySelector('circle')).toBeNull(); }); + + it('renders PostHog with its mark instead of the unknown-platform dot', () => { + const { container } = render(React.createElement(PlatformGlyph, { type: 'posthog' })); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('width', '20'); + expect(svg).toHaveAttribute('fill', 'currentColor'); + expect(svg?.querySelector('path')?.getAttribute('d')).toContain('M9.854 14.5 5 9.647'); + expect(svg?.querySelector('circle')).toBeNull(); + }); }); diff --git a/frontend/src/v2/icons/platforms/index.tsx b/frontend/src/v2/icons/platforms/index.tsx index a0bfeefcca..0df9adebc5 100644 --- a/frontend/src/v2/icons/platforms/index.tsx +++ b/frontend/src/v2/icons/platforms/index.tsx @@ -71,6 +71,11 @@ const Airtable = () => ( ); +// Simple Icons PostHog mark, CC0 1.0. Monochrome, inheriting the row's ink. +const PostHog = () => ( + +); + // Original Commonly drawing; no third-party glyph or licensed asset. This is // a simplified calendar symbol, not Google's trademarked brand mark. const GoogleCalendar = () => ( @@ -86,6 +91,7 @@ export const PLATFORM_GLYPHS: Record = { 'google-calendar': GoogleCalendar, atlassian: Atlassian, airtable: Airtable, + posthog: PostHog, telegram: Telegram, slack: Slack, discord: Discord,