From 8a056ac1c97398a671750d0d3196f25829bd3151 Mon Sep 17 00:00:00 2001 From: keysersoft Date: Sat, 3 Oct 2026 09:59:02 +0200 Subject: [PATCH 1/2] Catalog adapters for a vendor's own MCP server; Splunk first - Adapter type MCP: at install the tools come from the workspace's server (tools/list), so it gets exactly what its server version offers; the catalog's list is the snapshot shown in the store and the fallback when the server cannot be reached. Listing the tools is the install check. - Catalog policy on top: "enabled": false installs a tool switched off, catalog annotations override the server's; catalog updates never rewrite or retire an MCP adapter's tools - Splunk adapter over the official Splunk MCP Server app (16 tools, dashboard writes off by default), with setup for the encrypted MCP token, mcp_tool_execute and the port 8089 allow list - 266 adapters --- CHANGELOG.md | 2 +- CITATION.cff | 2 +- README.de.md | 6 +- README.ja.md | 6 +- README.md | 6 +- README.zh-CN.md | 6 +- docs/tool-definition.md | 22 +- glama.json | 2 +- package.json | 2 +- .../backend/src/adapters/adapters.service.ts | 61 +- packages/backend/src/adapters/catalog.spec.ts | 9 + packages/backend/src/adapters/catalog.ts | 5 + .../backend/src/adapters/intl/splunk.json | 525 ++++++++++++++++++ .../backend/src/adapters/mcp-adapter.spec.ts | 133 +++++ .../backend/src/adapters/mcp-adapter.util.ts | 47 ++ .../connectors/catalog-resync.service.spec.ts | 46 ++ .../src/connectors/catalog-resync.service.ts | 9 +- .../src/connectors/connectors.service.ts | 55 ++ .../public/logos/connectors/splunk.svg | 1 + server.json | 2 +- 20 files changed, 920 insertions(+), 27 deletions(-) create mode 100644 packages/backend/src/adapters/intl/splunk.json create mode 100644 packages/backend/src/adapters/mcp-adapter.spec.ts create mode 100644 packages/backend/src/adapters/mcp-adapter.util.ts create mode 100644 packages/frontend/public/logos/connectors/splunk.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e505c1..fd42e490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Each release ships with a structured changelog covering fixes, new features, bre - [Latest release](https://github.com/HelpCode-ai/anythingmcp/releases/latest) - [All releases](https://github.com/HelpCode-ai/anythingmcp/releases) -- [Roadmap](ROADMAP.md) +- [Roadmap](docs/ROADMAP.md) ## Versioning diff --git a/CITATION.cff b/CITATION.cff index d71af617..5098f798 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,7 +13,7 @@ abstract: > server that turns REST/OpenAPI, SOAP/WSDL and GraphQL APIs, SQL/NoSQL databases and other MCP servers into tools for AI clients such as Claude, ChatGPT, Google Gemini, GitHub Copilot and Cursor, without writing code. - It ships 265 pre-built adapters, with a focus on ERP and e-commerce + It ships 266 pre-built adapters, with a focus on ERP and e-commerce systems (SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO, etc.), a per-workspace knowledge graph served over MCP, per-tool response mapping, diff --git a/README.de.md b/README.de.md index 598e404b..c99ba566 100644 --- a/README.de.md +++ b/README.de.md @@ -1,5 +1,5 @@

- AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 265 Connectors, 21 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 266 Connectors, 21 davon ohne API-Schlüssel.

AnythingMCP: selbst gehostetes MCP-Gateway

@@ -19,7 +19,7 @@

AnythingMCP ist ein quelloffenes, selbst gehostetes MCP-Gateway, das jedes REST-/OpenAPI-, SOAP-, GraphQL-, OData- oder SQL-System in MCP-Tools für Claude, ChatGPT und Copilot verwandelt, ohne dass du einen MCP-Server programmierst.
- Es bringt 265 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 21 davon kommen ohne API-Schlüssel aus. + Es bringt 266 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 21 davon kommen ohne API-Schlüssel aus.

@@ -111,7 +111,7 @@ Tools werden zur Laufzeit registriert, ohne Neustart. `{{VAR}}`-Werte pro Connec ## Connector-Katalog -265 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen. +266 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen. | Kategorie | Beispiele | |---|---| diff --git a/README.ja.md b/README.ja.md index c5d1bfc4..2c2241a7 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

- AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。265 のコネクター、うち 21 は API キー不要。 + AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。266 のコネクター、うち 21 は API キー不要。

AnythingMCP:セルフホスト型 MCP ゲートウェイ

@@ -19,7 +19,7 @@

AnythingMCP は、オープンソースのセルフホスト型 MCP ゲートウェイです。MCP サーバーを書かずに、REST/OpenAPI、SOAP、GraphQL、OData、SQL のあらゆるシステムを Claude、ChatGPT、Copilot 用の MCP ツールに変換します。
- SAP、Etsy、weclapp、Amazon Seller などを含む 265 種類の既製アダプターを同梱しており、うち 21 は API キー不要です。 + SAP、Etsy、weclapp、Amazon Seller などを含む 266 種類の既製アダプターを同梱しており、うち 21 は API キー不要です。

@@ -111,7 +111,7 @@ amd64 ではイメージの取得に約 30 秒、その 24 秒後に API が利 ## コネクターカタログ -265 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 +266 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 | カテゴリー | 例 | |---|---| diff --git a/README.md b/README.md index 93e6053c..5a5163d3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 265 connectors, 21 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 266 connectors, 21 of them with no API key.

AnythingMCP: self-hosted MCP gateway

@@ -19,7 +19,7 @@

AnythingMCP is an open-source, self-hosted MCP gateway that turns any REST/OpenAPI, SOAP, GraphQL, OData or SQL system into MCP tools for Claude, ChatGPT and Copilot, without writing an MCP server.
- It ships 265 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 21 of them need no API key. + It ships 266 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 21 of them need no API key.

@@ -103,7 +103,7 @@ Tools register at runtime, without a restart. Per-connector `{{VAR}}` values are ## Connector catalog -265 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. +266 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. | Category | Examples | |---|---| diff --git a/README.zh-CN.md b/README.zh-CN.md index 3b5362e5..19570562 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,5 @@

- AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:265 个连接器,其中 21 个无需 API 密钥。 + AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:266 个连接器,其中 21 个无需 API 密钥。

AnythingMCP:自行托管的 MCP 网关

@@ -19,7 +19,7 @@

AnythingMCP 是一个开源、可自行托管的 MCP 网关,无需编写 MCP 服务器,即可将任意 REST/OpenAPI、SOAP、GraphQL、OData 或 SQL 系统转化为 Claude、ChatGPT 和 Copilot 可用的 MCP 工具。
- 它自带 265 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 21 个无需 API 密钥。 + 它自带 266 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 21 个无需 API 密钥。

@@ -111,7 +111,7 @@ docker compose up -d ## 连接器目录 -共 265 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 +共 266 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 | 类别 | 示例 | |---|---| diff --git a/docs/tool-definition.md b/docs/tool-definition.md index 709675ff..20a98301 100644 --- a/docs/tool-definition.md +++ b/docs/tool-definition.md @@ -37,7 +37,8 @@ leaving an unresolved `{{IS24_CONSUMER_KEY}}` in the credentials. Each entry in `tools` needs a string `name`, a useful `description`, and its JSON-Schema `parameters` when it accepts input. Parameter properties should -include descriptions for the model. +include descriptions for the model. `"enabled": false` installs a tool switched +off, for writes a workspace should turn on deliberately. See [connector configuration](#connector) and [authentication](#authentication) for the nested connector fields. @@ -95,6 +96,25 @@ The body is signed exactly as it will be sent — signing a different rendering of the same object is the usual way an HMAC integration fails with an error that blames the key. +### MCP adapters (a vendor's own MCP server) + +When a vendor already runs an official MCP server, the adapter bridges it +instead of describing its API again. `connector.type` is `MCP`, `baseUrl` is +the server's MCP endpoint (variables allowed, e.g. +`https://{{SPLUNK_HOST}}:8089/services/mcp`), and authentication is whatever +the server accepts (usually `BEARER_TOKEN`). + +At install AnythingMCP asks that server for its tools (`tools/list`) and +installs what it lists, so a workspace gets exactly the tools of the server +version it runs. The adapter's `tools` array is a snapshot: it is what the +catalog shows, the fallback installed when the server cannot be reached at +install, and the place for policy. A snapshot tool with `"enabled": false` +installs switched off, and `annotations` set there override the server's. +Each tool's `endpointMapping` is `{ "method": "", "path": "/mcp" }`. +Listing the tools is also the install check, so no `probe` is needed, and a +catalog update never rewrites or retires the tools of an MCP adapter: the +server owns them. See `intl/splunk.json`. + ### DATABASE adapters A `DATABASE` adapter points at a database instead of an HTTP API, so several diff --git a/glama.json b/glama.json index 22d16b5a..bb1d7566 100644 --- a/glama.json +++ b/glama.json @@ -3,5 +3,5 @@ "maintainers": [ "keysersoft" ], - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 265 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0." + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 266 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0." } diff --git a/package.json b/package.json index bcc696d7..c8bd71e3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anythingmcp", "version": "0.17.0", - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 265 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).", + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 266 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).", "private": true, "license": "AGPL-3.0-only", "engines": { diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 139b95df..22f7b29d 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -21,6 +21,7 @@ import { STARTER_PACK } from './starter-pack'; import { ConnectorsService } from '../connectors/connectors.service'; import { classifyToolExecutionError } from '../connectors/connector-error.util'; import { applyResponseTransform } from '../connectors/response-transform.util'; +import { describeDiscoveredTools, mergeDiscoveredMcpTools } from './mcp-adapter.util'; @Injectable() export class AdaptersService { @@ -226,14 +227,57 @@ export class AdaptersService { let toolsCreated = 0; - for (const tool of adapter.tools) { + // An adapter for a vendor's own MCP server installs the tools that server + // lists now; the catalog's copy is the fallback. Listing them is also the + // proof that the address and token work, so it stands in for the probe. + let toolsToCreate = adapter.tools; + let mcpProbe: ImportProbeResult | null = null; + if (adapter.connector.type === 'MCP') { + const started = Date.now(); + try { + const discovered = await this.connectors.discoverRemoteMcpTools(connector); + toolsToCreate = mergeDiscoveredMcpTools(adapter.tools, discovered); + mcpProbe = { + ok: true, + toolName: 'tools/list', + durationMs: Date.now() - started, + sample: describeDiscoveredTools(discovered), + }; + } catch (err: any) { + // The MCP SDK reports the HTTP status of a failed request as `code`. + const status: number | undefined = + typeof err?.status === 'number' + ? err.status + : typeof err?.code === 'number' && err.code >= 400 + ? err.code + : undefined; + const upstream = String(err?.message ?? err ?? 'unknown error').slice(0, 400); + const { hint } = classifyToolExecutionError({ + status, + authType: adapter.connector.authType, + message: upstream, + }); + this.logger.warn( + `Could not list the tools of "${slug}" at install (${upstream}); installed the catalog's list`, + ); + mcpProbe = { + ok: false, + toolName: 'tools/list', + durationMs: Date.now() - started, + status: status ?? null, + message: `${upstream} ${hint}`.trim(), + }; + } + } + + for (const tool of toolsToCreate) { try { await this.prisma.mcpTool.create({ data: { connectorId: connector.id, name: tool.name, description: tool.description, - isEnabled: true, + isEnabled: tool.enabled !== false, // Seed the proxy preference from the adapter spec (default off). useProxy: tool.useProxy === true, parameters: tool.parameters as any, @@ -259,11 +303,14 @@ export class AdaptersService { `Imported adapter "${slug}" as connector ${connector.id} with ${toolsCreated} tools`, ); - const probe = await this.runImportProbe( - adapter, - connector.id, - resolvedAuthConfig as Record | null, - ); + const probe = + adapter.connector.type === 'MCP' + ? mcpProbe + : await this.runImportProbe( + adapter, + connector.id, + resolvedAuthConfig as Record | null, + ); return { connectorId: connector.id, toolsCreated, probe }; } diff --git a/packages/backend/src/adapters/catalog.spec.ts b/packages/backend/src/adapters/catalog.spec.ts index 630267a8..75a36bd8 100644 --- a/packages/backend/src/adapters/catalog.spec.ts +++ b/packages/backend/src/adapters/catalog.spec.ts @@ -296,6 +296,15 @@ describe('adapter catalog', () => { (_name, tool) => { const em = tool.endpointMapping as Record; + // A tool of a vendor's MCP server is called by name on the remote + // server: `method` is that name and `path` the MCP endpoint, not an + // HTTP verb and URL. + if (adapter.connector.type === 'MCP') { + expect(em.method).toBe(tool.name); + expect(typeof em.path).toBe('string'); + return; + } + const isDatabase = adapter.connector.type === 'DATABASE'; const allowed = isDatabase ? VALID_DATABASE_METHODS diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index da015732..1bd6e2df 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -208,6 +208,7 @@ import * as signwell from './intl/signwell.json'; import * as slab from './intl/slab.json'; import * as snov from './intl/snov.json'; import * as sorare from './intl/sorare.json'; +import * as splunk from './intl/splunk.json'; import * as statsig from './intl/statsig.json'; import * as streak from './intl/streak.json'; import * as substack from './intl/substack.json'; @@ -355,6 +356,9 @@ export interface AdapterDefinition extends AdapterMeta { * `readOnlyHint` for a read exposed over POST. Seeds mcp_tools.annotations * on import, the same column a user override lives in. */ annotations?: Record; + /** `false` installs the tool switched off, for writes a workspace should + * opt into rather than get by default. Default true. */ + enabled?: boolean; }>; } @@ -631,6 +635,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ slab as unknown as AdapterDefinition, snov as unknown as AdapterDefinition, sorare as unknown as AdapterDefinition, + splunk as unknown as AdapterDefinition, statsig as unknown as AdapterDefinition, streak as unknown as AdapterDefinition, substack as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/splunk.json b/packages/backend/src/adapters/intl/splunk.json new file mode 100644 index 00000000..8759ef43 --- /dev/null +++ b/packages/backend/src/adapters/intl/splunk.json @@ -0,0 +1,525 @@ +{ + "slug": "splunk", + "name": "Splunk", + "description": "Search Splunk logs and metrics from Claude, ChatGPT or Copilot through Splunk's official MCP Server: run SPL and saved searches, list indexes, hosts and sources, read knowledge objects, and draft SPL with Splunk AI Assistant.", + "instructions": "This connector bridges the **Splunk MCP Server**, the official app by Splunk (Splunkbase app 7931), on your Splunk Cloud Platform or Splunk Enterprise search head. Splunk's own server provides the tools: SPL searches, saved searches, indexes, hosts and sources, knowledge objects, KV Store, users and, where Splunk AI Assistant is available, the `saia_*` tools that write, explain and optimise SPL. AnythingMCP puts them behind your MCP endpoint with OAuth for Claude and ChatGPT, per-tool roles, the audit log and response mapping, next to your other systems.\n\n**The tools come from your server.** At install AnythingMCP asks your Splunk MCP Server which tools it has, so you get exactly the set of the app version you run. After upgrading the app in Splunk, open the connector and click **Discover tools** to pick up new ones.\n\n**Setup (about 10 minutes, needs a Splunk admin)**:\n1. Install **Splunk MCP Server** from Splunkbase on the search head (or search head cluster). Make sure REST API access and token authentication are enabled.\n2. Give the role that will run the tools the `mcp_tool_execute` capability, and restrict that role to the indexes the AI may read. The role is the real boundary: the agent sees whatever this user sees.\n3. In the Splunk MCP Server app, generate an **encrypted MCP token** for that user and copy it (Splunk shows it once). An ordinary Splunk user or service token does not work: Splunk refuses it with \"invalid token audience\" (HTTP 403).\n4. Fill in the variables and install:\n - `SPLUNK_HOST`: the search head's host name, without `https://` and without a port, for example `yourstack.splunkcloud.com`. The connector calls `https://SPLUNK_HOST:8089/services/mcp`, the address the app shows. If yours uses another port or path, change the base URL in the connector editor after installing.\n - `SPLUNK_MCP_TOKEN`: the encrypted token from step 3. It is stored encrypted.\n\n**Network**: AnythingMCP connects to the management port (8089). On Splunk Cloud Platform that port only answers addresses on your stack's IP allow list for the search API (Admin Config Service), so add the address AnythingMCP calls from (on AnythingMCP Cloud, ask support for the current egress IP). Splunk Enterprise needs a certificate on 8089 that the AnythingMCP server trusts.\n\n**Safety**: Splunk marks `splunk_run_query` and `splunk_run_saved_search` as able to change data, because SPL can write (`collect`, `outputlookup`, `delete`). Give the token's user a role that cannot write to indexes or lookups. The two dashboard tools install switched off; turn them on per tool if the AI should create or change dashboards. Splunk admins can also disable tools for everyone in the app, and **Invalidate all MCP access tokens** there revokes every token at once.\n\n**Working with it**: start with `splunk_get_indexes` or `splunk_get_metadata` to see what data exists, always give searches a time range (`earliest`/`latest`) and a row limit, and let `saia_generate_spl` draft SPL you are unsure about. A 403 on install means the token is not an MCP token or the role lacks `mcp_tool_execute`; a timeout usually means the allow list or a firewall blocks port 8089.", + "region": "intl", + "category": "monitoring", + "icon": "splunk", + "docsUrl": "https://help.splunk.com/en/splunk-enterprise/mcp-server-for-splunk-platform/1.1/connecting-to-the-mcp-server-and-settings", + "requiredEnvVars": [ + "SPLUNK_HOST", + "SPLUNK_MCP_TOKEN" + ], + "connector": { + "name": "Splunk", + "type": "MCP", + "baseUrl": "https://{{SPLUNK_HOST}}:8089/services/mcp", + "authType": "BEARER_TOKEN", + "authConfig": { + "token": "{{SPLUNK_MCP_TOKEN}}" + } + }, + "tools": [ + { + "name": "saia_ask_splunk_question", + "description": "Ask natural language questions about Splunk using Splunk AI Assistant. Get explanations about Splunk commands, concepts, features, and best practices.", + "parameters": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string", + "pattern": "^[\\s\\S]{1,10000}$", + "description": "Natural language question about Splunk\nValidation: Prompt is required and must be between 1 and 10000 characters (submitted: {length})" + }, + "chat_history": { + "type": "string", + "pattern": "^[\\s\\S]{1,5000}$", + "description": "Chat history for context\nValidation: Chat history is required and limited to 5000 characters" + }, + "additional_context": { + "type": "string", + "description": "Additional context to include in generated SPL" + } + } + }, + "endpointMapping": { + "method": "saia_ask_splunk_question", + "path": "/mcp" + } + }, + { + "name": "saia_explain_spl", + "description": "Explain SPL queries in natural language using Splunk AI Assistant. Converts complex SPL commands into human-readable explanations.", + "parameters": { + "type": "object", + "required": [ + "spl" + ], + "properties": { + "spl": { + "type": "string", + "pattern": "^[\\s\\S]{1,10000}$", + "description": "SPL query to explain in natural language\nValidation: SPL query is required and must be between 1 and 10000 characters (submitted: {length})" + }, + "chat_history": { + "type": "string", + "pattern": "^[\\s\\S]{1,5000}$", + "description": "Chat history for context\nValidation: Chat history is required and limited to 5000 characters" + }, + "additional_context": { + "type": "string", + "description": "Additional context to include in generated SPL" + } + } + }, + "endpointMapping": { + "method": "saia_explain_spl", + "path": "/mcp" + } + }, + { + "name": "saia_generate_spl", + "description": "Generate SPL from natural language queries using Splunk AI Assistant.", + "parameters": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string", + "pattern": "^[\\s\\S]{1,10000}$", + "description": "Natural language query for generating SPL\nValidation: Prompt is required and must be between 1 and 10000 characters (submitted: {length})" + }, + "spl_only": { + "type": "boolean", + "default": false, + "description": "If set to true, returns only generated SPL with no additional explanation" + }, + "chat_history": { + "type": "string", + "pattern": "^[\\s\\S]{1,5000}$", + "description": "Chat history for context\nValidation: Chat history is required and limited to 5000 characters" + }, + "additional_context": { + "type": "string", + "description": "Additional context to include in generated SPL" + } + } + }, + "endpointMapping": { + "method": "saia_generate_spl", + "path": "/mcp" + } + }, + { + "name": "saia_optimize_spl", + "description": "Optimize SPL (Search Processing Language) queries using Splunk AI Assistant. Improves query performance, efficiency, and follows best practices.", + "parameters": { + "type": "object", + "required": [ + "spl" + ], + "properties": { + "spl": { + "type": "string", + "pattern": "^[\\s\\S]{1,10000}$", + "description": "SPL query to optimize for better performance\nValidation: SPL query is required and must be between 1 and 10000 characters (submitted: {length})" + }, + "chat_history": { + "type": "string", + "pattern": "^[\\s\\S]{1,5000}$", + "description": "Chat history for context\nValidation: Chat history is required and limited to 5000 characters" + }, + "additional_context": { + "type": "string", + "description": "Additional context to include in generated SPL" + } + } + }, + "endpointMapping": { + "method": "saia_optimize_spl", + "path": "/mcp" + } + }, + { + "name": "splunk_create_dashboard", + "description": "Creates a new Splunk dashboard using Dashboard Studio JSON in the specified app namespace.", + "parameters": { + "type": "object", + "required": [ + "eai_data", + "dashboard_name" + ], + "properties": { + "app": { + "type": "string", + "default": "search", + "description": "App namespace to create the dashboard in" + }, + "eai_data": { + "type": "string", + "description": "The full Dashboard Studio JSON definition" + }, + "dashboard_name": { + "type": "string", + "description": "Internal name/ID for the dashboard" + } + } + }, + "endpointMapping": { + "method": "splunk_create_dashboard", + "path": "/mcp" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, + "enabled": false + }, + { + "name": "splunk_get_index_info", + "description": "Get detailed information about a specific Splunk index. Returns comprehensive configuration and status information for the specified index.", + "parameters": { + "type": "object", + "required": [ + "index_name" + ], + "properties": { + "index_name": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "Name of the index to get information about\nValidation: Index name can only contain alphanumeric characters, underscores (_), and hyphens (-)" + } + } + }, + "endpointMapping": { + "method": "splunk_get_index_info", + "path": "/mcp" + } + }, + { + "name": "splunk_get_indexes", + "description": "Get a list of indexes from Splunk. Indexes are data repositories where machine data is stored and organized.", + "parameters": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of results to return in this page. Omit this argument to use the server's configured default row limit. If you provide a value greater than the server's configured maximum row limit, the server uses the maximum instead." + }, + "offset": { + "type": "integer", + "default": 0, + "minimum": 0, + "description": "Zero-based index of the first result to return. Defaults to 0." + } + } + }, + "endpointMapping": { + "method": "splunk_get_indexes", + "path": "/mcp" + } + }, + { + "name": "splunk_get_info", + "description": "Get comprehensive information about the Splunk instance. Retrieves system information including version, hardware specs, and operational status.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "splunk_get_info", + "path": "/mcp" + } + }, + { + "name": "splunk_get_knowledge_objects", + "description": "Retrieve Splunk knowledge objects by type. Supports various knowledge object types including saved searches, alerts, field extractions, lookups, macros, data models, and more.", + "parameters": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "saved_searches", + "alerts", + "field_extractions", + "field_aliases", + "calculated_fields", + "lookups", + "automatic_lookups", + "lookup_transforms", + "macros", + "tags", + "data_models", + "workflow_actions", + "views", + "panels", + "apps", + "mltk_algorithms", + "mltk_models" + ], + "type": "string", + "description": "Type of knowledge object to retrieve" + }, + "row_limit": { + "type": "integer", + "default": 100, + "maximum": 1000, + "minimum": 1, + "description": "Maximum number of rows to return" + } + } + }, + "endpointMapping": { + "method": "splunk_get_knowledge_objects", + "path": "/mcp" + } + }, + { + "name": "splunk_get_kv_store_collections", + "description": "Get KV Store collection statistics including size, count, and storage information. Retrieves comprehensive metrics about all KV Store collections in the Splunk instance.", + "parameters": { + "type": "object", + "properties": { + "row_limit": { + "type": "integer", + "default": 100, + "maximum": 1000, + "minimum": 1, + "description": "Maximum number of rows to return" + } + } + }, + "endpointMapping": { + "method": "splunk_get_kv_store_collections", + "path": "/mcp" + } + }, + { + "name": "splunk_get_metadata", + "description": "Retrieve metadata about hosts or sources across one or more indexes in the selected time window.", + "parameters": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "hosts", + "sources" + ], + "type": "string", + "description": "Metadata category to list (hosts or sources)." + }, + "index": { + "type": "string", + "default": "*", + "pattern": "^[A-Za-z0-9_*-]+$", + "description": "Index name or wildcard pattern (e.g. main, app_*, *).\nValidation: Index pattern may contain letters, numbers, underscore, dash, and * wildcard" + }, + "row_limit": { + "type": "integer", + "default": 100, + "maximum": 1000, + "minimum": 1, + "description": "Maximum number of rows to return" + }, + "latest_time": { + "type": "string", + "default": "now", + "examples": [ + "now" + ], + "description": "End time for search (e.g., now, -1h)" + }, + "earliest_time": { + "type": "string", + "default": "-24h", + "examples": [ + "-24h" + ], + "description": "Start time for search (e.g., -24h, -1d)" + } + } + }, + "endpointMapping": { + "method": "splunk_get_metadata", + "path": "/mcp" + } + }, + { + "name": "splunk_get_user_info", + "description": "Retrieves detailed information about the currently authenticated user including roles and permissions. Returns comprehensive user profile data for the current session.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "splunk_get_user_info", + "path": "/mcp" + } + }, + { + "name": "splunk_get_user_list", + "description": "Get a list of users from Splunk. Retrieves information about all users including authentication details, roles, and account status.", + "parameters": { + "type": "object", + "properties": { + "row_limit": { + "type": "integer", + "default": 100, + "maximum": 1000, + "minimum": 1, + "description": "Maximum number of rows to return" + } + } + }, + "endpointMapping": { + "method": "splunk_get_user_list", + "path": "/mcp" + } + }, + { + "name": "splunk_run_query", + "description": "Execute a Splunk search query and return the results. Use this to retrieve log data, perform aggregations, analyze events, and extract insights. Always set the 'app' parameter when the user specifies an app context — omitting it means app-scoped field extractions will not be applied.", + "parameters": { + "type": "object", + "required": [ + "query" + ], + "properties": { + "app": { + "type": "string", + "pattern": "^(?!\\.{1,2}$)(?!-$)[A-Za-z0-9_.-]+$", + "description": "Splunk app context to run the SPL query in (e.g., 'search', 'enterprise_security'). Set this when the user specifies an app — omitting it means app-scoped field extractions and lookups will not be applied.\nValidation: App name may only contain letters, numbers, underscores, hyphens, and dots." + }, + "query": { + "type": "string", + "pattern": "^[\\s\\S]{1,10000}$", + "description": "Valid SPL query to execute\nValidation: SPL query must be between 1 and 10000 characters (submitted: {length})" + }, + "row_limit": { + "type": "integer", + "default": 100, + "maximum": 1000, + "minimum": 1, + "description": "Maximum number of rows to return" + }, + "latest_time": { + "type": "string", + "default": "now", + "description": "End time for search (e.g., now, -1h)" + }, + "earliest_time": { + "type": "string", + "default": "-6h", + "description": "Start time for search (e.g., -6h, -1d)" + }, + "workload_pool": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "Splunk workload pool to run this search in.\nValidation: Workload pool name may only contain letters, numbers, underscores, hyphens, and dots." + } + } + }, + "endpointMapping": { + "method": "splunk_run_query", + "path": "/mcp" + } + }, + { + "name": "splunk_run_saved_search", + "description": "Execute a Splunk saved search by name and return its results. Saved searches are pre-defined SPL queries stored in Splunk. Use get_knowledge_objects with type='saved_searches' to discover available saved searches and their app context. If the saved search name is unique across apps, the app is resolved automatically. If the same name exists in multiple apps, the alphabetically first app is used; pass the 'app' parameter to target a specific app. If the saved search contains placeholder tokens like $token_name$, pass replacement values as key=value pairs in the args parameter.", + "parameters": { + "type": "object", + "required": [ + "saved_search_name" + ], + "properties": { + "app": { + "type": "string", + "pattern": "^(?!\\.{1,2}$)(?!-$)[A-Za-z0-9_.-]+$", + "description": "Splunk app context to run the saved search in (e.g., 'search', 'enterprise_security'). If omitted, the app is auto-resolved; when the name exists in multiple apps the alphabetically first app is used.\nValidation: App name may only contain letters, numbers, underscores, hyphens, and dots." + }, + "args": { + "type": "string", + "default": "", + "pattern": "^(\\s*[A-Za-z_][A-Za-z0-9_.]*\\s*=\\s*\"(\\\\\"|[^\"\\|\\[\\]`\\n\\r;])*\"(\\s+|$))*$", + "description": "Optional replacement arguments for saved search tokens\nValidation: args must be space-separated key=\"value\" token assignments only (e.g. host=\"web01\" sourcetype=\"syslog\"). Pipe operators, brackets, backticks, semicolons, and newlines are not allowed in values." + }, + "row_limit": { + "type": "integer", + "default": 100, + "maximum": 1000, + "minimum": 1, + "description": "Maximum number of rows to return" + }, + "latest_time": { + "type": "string", + "description": "Override the saved search's default end time (e.g., now, -1h). If omitted, uses the time range configured in the saved search." + }, + "earliest_time": { + "type": "string", + "description": "Override the saved search's default start time (e.g., -24h, -7d, 2024-01-01T00:00:00). If omitted, uses the time range configured in the saved search." + }, + "saved_search_name": { + "type": "string", + "pattern": "^(?=.*\\S)[\\s\\S]{1,500}$", + "description": "Name of the saved search to execute\nValidation: Saved search name is required, it cannot be blank and is limited to 500 characters." + } + } + }, + "endpointMapping": { + "method": "splunk_run_saved_search", + "path": "/mcp" + } + }, + { + "name": "splunk_update_dashboard", + "description": "Updates an existing Splunk dashboard's definition using Dashboard Studio JSON.", + "parameters": { + "type": "object", + "required": [ + "eai_data", + "dashboard_name" + ], + "properties": { + "app": { + "type": "string", + "default": "search", + "description": "App namespace the dashboard lives in" + }, + "eai_data": { + "type": "string", + "description": "The new/updated Dashboard Studio JSON definition" + }, + "dashboard_name": { + "type": "string", + "description": "The exact name/ID of the existing dashboard to update" + } + } + }, + "endpointMapping": { + "method": "splunk_update_dashboard", + "path": "/mcp" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "enabled": false + } + ] +} diff --git a/packages/backend/src/adapters/mcp-adapter.spec.ts b/packages/backend/src/adapters/mcp-adapter.spec.ts new file mode 100644 index 00000000..87aac020 --- /dev/null +++ b/packages/backend/src/adapters/mcp-adapter.spec.ts @@ -0,0 +1,133 @@ +import { AdaptersService } from './adapters.service'; +import { getAdapter } from './catalog'; +import { describeDiscoveredTools, mergeDiscoveredMcpTools } from './mcp-adapter.util'; +import type { DiscoveredMcpTool } from '../connectors/connectors.service'; + +/** + * Catalog adapters that bridge a vendor's own MCP server (Splunk first): + * the tools come from the workspace's server at install, the catalog only + * adds policy (tools switched off by default, annotation overrides), and the + * catalog's snapshot is the fallback when the server cannot be reached. + */ + +const remote = (name: string, extra: Partial = {}): DiscoveredMcpTool => ({ + name, + description: `${name} from the server`, + parameters: { type: 'object', properties: { q: { type: 'string' } } }, + endpointMapping: { method: name, path: '/mcp' }, + outputSchema: null, + annotations: { readOnlyHint: true }, + ...extra, +}); + +describe('mergeDiscoveredMcpTools', () => { + const catalog = [ + { name: 'a_read', description: 'old', parameters: {}, endpointMapping: { method: 'a_read', path: '/mcp' } }, + { + name: 'a_write', + description: 'old', + parameters: {}, + endpointMapping: { method: 'a_write', path: '/mcp' }, + annotations: { readOnlyHint: false, destructiveHint: true }, + enabled: false, + }, + { name: 'a_gone', description: 'old', parameters: {}, endpointMapping: { method: 'a_gone', path: '/mcp' } }, + ]; + + const merged = mergeDiscoveredMcpTools(catalog, [remote('a_read'), remote('a_write'), remote('a_new')]); + + it('installs what the server lists, as the server describes it', () => { + expect(merged.map((t) => t.name)).toEqual(['a_read', 'a_write', 'a_new']); + expect(merged[0].description).toBe('a_read from the server'); + expect(merged[0].parameters).toEqual(remote('a_read').parameters); + }); + + it('keeps the catalog policy: switched off, annotations overridden', () => { + const write = merged.find((t) => t.name === 'a_write')!; + expect(write.enabled).toBe(false); + expect(write.annotations).toEqual({ readOnlyHint: false, destructiveHint: true }); + expect(merged.find((t) => t.name === 'a_read')!.enabled).toBeUndefined(); + expect(merged.find((t) => t.name === 'a_read')!.annotations).toEqual({ readOnlyHint: true }); + }); + + it('drops catalog tools the server no longer has', () => { + expect(merged.find((t) => t.name === 'a_gone')).toBeUndefined(); + }); + + it('describes the discovered tools in one line', () => { + expect(describeDiscoveredTools([{ name: 'x' }, { name: 'y' }])).toBe('2 tools on the server: x, y'); + expect(describeDiscoveredTools([{ name: 'a' }, { name: 'b' }, { name: 'c' }], 2)).toBe( + '3 tools on the server: a, b, … (1 more)', + ); + }); +}); + +describe('importAdapter for an MCP adapter (splunk)', () => { + const build = (discover: jest.Mock) => { + const created: any[] = []; + const prisma = { + connector: { + create: jest.fn(async ({ data }: any) => ({ id: 'c1', ...data })), + }, + mcpTool: { + create: jest.fn(async ({ data }: any) => { + created.push(data); + return data; + }), + }, + }; + const service = new AdaptersService( + prisma as any, + { reloadConnectorTools: jest.fn() } as any, + { get: (k: string) => (k === 'ENCRYPTION_KEY' ? 'a'.repeat(48) : undefined) } as any, + { discoverRemoteMcpTools: discover } as any, + ); + return { service, prisma, created }; + }; + const creds = { SPLUNK_HOST: 'acme.splunkcloud.com', SPLUNK_MCP_TOKEN: 'tok' }; + + it('creates the connector on the management port and installs the server tools', async () => { + const discover = jest.fn().mockResolvedValue([ + remote('splunk_run_query'), + remote('splunk_update_dashboard'), + remote('splunk_brand_new_tool'), + ]); + const { service, prisma, created } = build(discover); + const out = await service.importAdapter('splunk', 'u1', 'o1', creds); + + const data = prisma.connector.create.mock.calls[0][0].data; + expect(data.type).toBe('MCP'); + expect(data.baseUrl).toBe('https://acme.splunkcloud.com:8089/services/mcp'); + expect(data.authType).toBe('BEARER_TOKEN'); + expect(discover).toHaveBeenCalledTimes(1); + + expect(created.map((t) => t.name)).toEqual([ + 'splunk_run_query', + 'splunk_update_dashboard', + 'splunk_brand_new_tool', + ]); + // The catalog switches the dashboard writes off; the server's other tools stay on. + expect(created.find((t) => t.name === 'splunk_update_dashboard').isEnabled).toBe(false); + expect(created.find((t) => t.name === 'splunk_run_query').isEnabled).toBe(true); + expect(created.every((t) => t.origin === 'catalog')).toBe(true); + + expect(out.toolsCreated).toBe(3); + expect(out.probe).toMatchObject({ ok: true, toolName: 'tools/list' }); + expect((out.probe as any).sample).toContain('3 tools on the server'); + }); + + it('falls back to the catalog snapshot and reports why when the server cannot be listed', async () => { + const err = Object.assign(new Error('Error POSTing to endpoint (HTTP 403): invalid token audience'), { + code: 403, + }); + const discover = jest.fn().mockRejectedValue(err); + const { service, created } = build(discover); + const out = await service.importAdapter('splunk', 'u1', 'o1', creds); + + const snapshot = getAdapter('splunk')!.tools; + expect(created).toHaveLength(snapshot.length); + expect(created.find((t) => t.name === 'splunk_create_dashboard').isEnabled).toBe(false); + expect(out.probe).toMatchObject({ ok: false, toolName: 'tools/list', status: 403 }); + expect((out.probe as any).message).toContain('invalid token audience'); + }); +}); diff --git a/packages/backend/src/adapters/mcp-adapter.util.ts b/packages/backend/src/adapters/mcp-adapter.util.ts new file mode 100644 index 00000000..9195e292 --- /dev/null +++ b/packages/backend/src/adapters/mcp-adapter.util.ts @@ -0,0 +1,47 @@ +import type { AdapterDefinition } from './catalog'; +import type { DiscoveredMcpTool } from '../connectors/connectors.service'; + +type CatalogTool = AdapterDefinition['tools'][number]; + +/** + * The tools to install for a catalog adapter that bridges a vendor's own MCP + * server (connector type MCP). + * + * The server is the authority on which tools exist and what they take: a + * workspace on an older or newer server version gets exactly the tools that + * version has. The catalog's list is a snapshot, used for the listing and as + * the fallback when the server cannot be reached at install. What the catalog + * adds on top is policy: a tool it marks `enabled: false` (a write a + * workspace should opt into) installs switched off, and annotations it sets + * override the server's. + * + * Tools the server has and the catalog does not know are installed as the + * server describes them; catalog tools the server no longer has are left out. + */ +export function mergeDiscoveredMcpTools( + catalogTools: CatalogTool[], + discovered: DiscoveredMcpTool[], +): CatalogTool[] { + const policy = new Map(catalogTools.map((t) => [t.name, t])); + return discovered.map((d) => { + const own = policy.get(d.name); + return { + name: d.name, + description: d.description, + parameters: d.parameters, + endpointMapping: d.endpointMapping, + ...(d.outputSchema ? { outputSchema: d.outputSchema } : {}), + ...(own?.annotations || d.annotations + ? { annotations: { ...(d.annotations ?? {}), ...(own?.annotations ?? {}) } } + : {}), + ...(own?.enabled === false ? { enabled: false } : {}), + }; + }); +} + +/** One line for the install form: how many tools the server offers, and some names. */ +export function describeDiscoveredTools(tools: { name: string }[], shown = 12): string { + const names = tools.slice(0, shown).map((t) => t.name); + const more = tools.length > shown ? `, … (${tools.length - shown} more)` : ''; + return `${tools.length} tools on the server: ${names.join(', ')}${more}`; +} diff --git a/packages/backend/src/connectors/catalog-resync.service.spec.ts b/packages/backend/src/connectors/catalog-resync.service.spec.ts index 1efe9cb7..ebcd6ffc 100644 --- a/packages/backend/src/connectors/catalog-resync.service.spec.ts +++ b/packages/backend/src/connectors/catalog-resync.service.spec.ts @@ -430,3 +430,49 @@ describe('CatalogResyncService — the user\'s own tools are never touched', () expect(tx.connector.update).not.toHaveBeenCalled(); }); }); + +describe('CatalogResyncService.computeDiff for an MCP adapter', () => { + // The workspace's own MCP server owns the tool list: a catalog snapshot that + // differs from it (another server version) must not be offered as an update, + // and tools the server added must not be retired. + it('never adds, rewrites or retires the tools of an MCP adapter', async () => { + const adapter = getAdapter('splunk')!; + const connector = { + id: 'c1', + name: adapter.connector.name, + baseUrl: 'https://acme.splunkcloud.com:8089/services/mcp', + instructions: adapter.instructions ?? null, + createdAt: new Date('2026-10-01T00:00:00Z'), + config: { + adapterSlug: 'splunk', + adapterVersion: adapter.version, + instructionsBaseline: hashInstructions(adapter.instructions), + }, + tools: [ + { + id: 't1', + name: 'splunk_run_query', + description: 'what this server version says', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + endpointMapping: { method: 'splunk_run_query', path: '/mcp' }, + origin: 'catalog', + deprecatedAt: null, + }, + { + id: 't2', + name: 'splunk_tool_from_a_newer_server', + description: 'new upstream tool', + parameters: {}, + endpointMapping: { method: 'splunk_tool_from_a_newer_server', path: '/mcp' }, + origin: 'catalog', + deprecatedAt: null, + }, + ], + }; + const diff = await serviceFor(connector).computeDiff('c1'); + expect(diff!.updated).toEqual([]); + expect(diff!.added).toEqual([]); + expect(diff!.removed).toEqual([]); + expect(diff!.isUpToDate).toBe(true); + }); +}); diff --git a/packages/backend/src/connectors/catalog-resync.service.ts b/packages/backend/src/connectors/catalog-resync.service.ts index 2354f9a5..b2634ccb 100644 --- a/packages/backend/src/connectors/catalog-resync.service.ts +++ b/packages/backend/src/connectors/catalog-resync.service.ts @@ -135,7 +135,12 @@ export class CatalogResyncService { const adapter = getAdapter(slug); if (!adapter) return null; - const catalogTools = adapter.tools as CatalogTool[]; + // An adapter for a vendor's own MCP server ships a snapshot of its tools; + // the workspace's server is the authority on them (it may run another + // version), so a catalog update never adds, rewrites or retires them. + // Instructions and the base URL are still compared below. + const serverOwnsTools = adapter.connector.type === 'MCP'; + const catalogTools = serverOwnsTools ? [] : (adapter.tools as CatalogTool[]); const catalogByName = new Map(catalogTools.map((t) => [t.name, t])); const existingByName = new Map(connector.tools.map((t) => [t.name, t])); @@ -166,7 +171,7 @@ export class CatalogResyncService { const catalogNames = new Set(catalogByName.keys()); const removed: string[] = []; const custom: string[] = []; - for (const et of connector.tools) { + for (const et of serverOwnsTools ? [] : connector.tools) { if (catalogByName.has(et.name)) continue; if (!CatalogResyncService.isCatalogTool(et, connector.createdAt, catalogNames)) { if (!et.deprecatedAt) custom.push(et.name); diff --git a/packages/backend/src/connectors/connectors.service.ts b/packages/backend/src/connectors/connectors.service.ts index c0107756..e63f3f1c 100644 --- a/packages/backend/src/connectors/connectors.service.ts +++ b/packages/backend/src/connectors/connectors.service.ts @@ -347,6 +347,51 @@ export class ConnectorsService { } } + /** + * The tools a remote MCP server offers right now, as tool definitions ready + * to store. The catalog's MCP adapters call this at install, so a workspace + * gets the tools its own server version has rather than the catalog's copy. + * Throws what the server or the transport threw. + */ + async discoverRemoteMcpTools(connector: { + name: string; + baseUrl: string; + authType: string; + authConfig: string | null; + headers: unknown; + envVars: unknown; + }): Promise { + const envVars = (connector.envVars as Record | null) || {}; + const authConfig = connector.authConfig + ? interpolateDeep(JSON.parse(decrypt(connector.authConfig, this.encryptionKey)), envVars) + : undefined; + const baseUrl = interpolateDeep(connector.baseUrl, envVars); + const headers = interpolateDeep( + (connector.headers as Record) || undefined, + envVars, + ); + assertNoUnresolvedPlaceholders( + { baseUrl, headers, authConfig }, + `the "${connector.name}" connector`, + ); + const remote = await this.mcpClientEngine.listTools({ + baseUrl, + authType: connector.authType, + authConfig, + headers, + }); + return remote.map((rt) => ({ + name: rt.name, + description: rt.description || `MCP tool: ${rt.name}`, + parameters: (rt.inputSchema as Record) || { type: 'object', properties: {} }, + // '/mcp' is the historical default that resolveMcpEndpointUrl() treats + // as unset, so the path in the connector's base URL is used (#501). + endpointMapping: { method: rt.name, path: '/mcp' }, + outputSchema: (rt.outputSchema as Record) ?? null, + annotations: (rt.annotations as Record) ?? null, + })); + } + private classifyTestError( error: any, healthcheckPath: string, @@ -1053,3 +1098,13 @@ export class ConnectorsService { ].join('\n'); } } + +/** A tool listed by a remote MCP server, shaped like a catalog tool. */ +export interface DiscoveredMcpTool { + name: string; + description: string; + parameters: Record; + endpointMapping: { method: string; path: string }; + outputSchema: Record | null; + annotations: Record | null; +} diff --git a/packages/frontend/public/logos/connectors/splunk.svg b/packages/frontend/public/logos/connectors/splunk.svg new file mode 100644 index 00000000..e84d30db --- /dev/null +++ b/packages/frontend/public/logos/connectors/splunk.svg @@ -0,0 +1 @@ +Splunk \ No newline at end of file diff --git a/server.json b/server.json index 4379bf2e..3269d112 100644 --- a/server.json +++ b/server.json @@ -1,7 +1,7 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.HelpCode-ai/anythingmcp", - "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 265 connectors: SAP, ERP.", + "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 266 connectors: SAP, ERP.", "repository": { "url": "https://github.com/HelpCode-ai/anythingmcp", "source": "github" From ad790e6feee05043573f8385e7f13403ab80fcb7 Mon Sep 17 00:00:00 2001 From: keysersoft Date: Sat, 3 Oct 2026 10:05:36 +0200 Subject: [PATCH 2/2] Splunk satellite (keysersoft/splunk-mcp-gateway); server annotations win over the catalog's - Satellite config + hand-written prompts, FAQ, auth and troubleshooting; topics measured; optional title so the repo is not named like Splunk's own product - The catalog snapshot carries Splunk's annotations again (read/write in the store and the satellite); at install the server's hints win and the catalog only fills what the server leaves out (the dashboard tools) - Satellite READMEs: drop the header row of hand-written troubleshooting tables whatever it is called (Jev showed a stray 'Symptom' row) --- docs/tool-definition.md | 3 +- .../backend/src/adapters/intl/splunk.json | 70 +++++++++++++++++++ .../backend/src/adapters/mcp-adapter.spec.ts | 5 +- .../backend/src/adapters/mcp-adapter.util.ts | 2 +- .../content/splunk-mcp-gateway/auth.md | 8 +++ .../content/splunk-mcp-gateway/faq.md | 14 ++++ .../content/splunk-mcp-gateway/prompts.md | 9 +++ .../splunk-mcp-gateway/troubleshooting.md | 8 +++ scripts/satellites/readme.mjs | 13 ++-- scripts/satellites/satellites.config.json | 37 ++++++++++ scripts/satellites/topic-counts.json | 7 ++ 11 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 scripts/satellites/content/splunk-mcp-gateway/auth.md create mode 100644 scripts/satellites/content/splunk-mcp-gateway/faq.md create mode 100644 scripts/satellites/content/splunk-mcp-gateway/prompts.md create mode 100644 scripts/satellites/content/splunk-mcp-gateway/troubleshooting.md diff --git a/docs/tool-definition.md b/docs/tool-definition.md index 20a98301..fe35c50b 100644 --- a/docs/tool-definition.md +++ b/docs/tool-definition.md @@ -109,7 +109,8 @@ installs what it lists, so a workspace gets exactly the tools of the server version it runs. The adapter's `tools` array is a snapshot: it is what the catalog shows, the fallback installed when the server cannot be reached at install, and the place for policy. A snapshot tool with `"enabled": false` -installs switched off, and `annotations` set there override the server's. +installs switched off, and `annotations` set there fill the hints the server +leaves out (the server's own hints win). Each tool's `endpointMapping` is `{ "method": "", "path": "/mcp" }`. Listing the tools is also the install check, so no `probe` is needed, and a catalog update never rewrites or retires the tools of an MCP adapter: the diff --git a/packages/backend/src/adapters/intl/splunk.json b/packages/backend/src/adapters/intl/splunk.json index 8759ef43..9b0ba966 100644 --- a/packages/backend/src/adapters/intl/splunk.json +++ b/packages/backend/src/adapters/intl/splunk.json @@ -49,6 +49,11 @@ "endpointMapping": { "method": "saia_ask_splunk_question", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -79,6 +84,11 @@ "endpointMapping": { "method": "saia_explain_spl", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -114,6 +124,11 @@ "endpointMapping": { "method": "saia_generate_spl", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -144,6 +159,11 @@ "endpointMapping": { "method": "saia_optimize_spl", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -201,6 +221,11 @@ "endpointMapping": { "method": "splunk_get_index_info", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -225,6 +250,11 @@ "endpointMapping": { "method": "splunk_get_indexes", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -237,6 +267,11 @@ "endpointMapping": { "method": "splunk_get_info", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -283,6 +318,11 @@ "endpointMapping": { "method": "splunk_get_knowledge_objects", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -303,6 +343,11 @@ "endpointMapping": { "method": "splunk_get_kv_store_collections", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -356,6 +401,11 @@ "endpointMapping": { "method": "splunk_get_metadata", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -368,6 +418,11 @@ "endpointMapping": { "method": "splunk_get_user_info", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -388,6 +443,11 @@ "endpointMapping": { "method": "splunk_get_user_list", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false, + "destructiveHint": false } }, { @@ -436,6 +496,11 @@ "endpointMapping": { "method": "splunk_run_query", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": false, + "openWorldHint": false, + "destructiveHint": true } }, { @@ -483,6 +548,11 @@ "endpointMapping": { "method": "splunk_run_saved_search", "path": "/mcp" + }, + "annotations": { + "readOnlyHint": false, + "openWorldHint": false, + "destructiveHint": true } }, { diff --git a/packages/backend/src/adapters/mcp-adapter.spec.ts b/packages/backend/src/adapters/mcp-adapter.spec.ts index 87aac020..23c773ec 100644 --- a/packages/backend/src/adapters/mcp-adapter.spec.ts +++ b/packages/backend/src/adapters/mcp-adapter.spec.ts @@ -42,10 +42,11 @@ describe('mergeDiscoveredMcpTools', () => { expect(merged[0].parameters).toEqual(remote('a_read').parameters); }); - it('keeps the catalog policy: switched off, annotations overridden', () => { + it('keeps the catalog policy: switched off; annotations fill what the server leaves out', () => { const write = merged.find((t) => t.name === 'a_write')!; expect(write.enabled).toBe(false); - expect(write.annotations).toEqual({ readOnlyHint: false, destructiveHint: true }); + // The server says readOnlyHint: true and wins on it; destructiveHint only the catalog has. + expect(write.annotations).toEqual({ readOnlyHint: true, destructiveHint: true }); expect(merged.find((t) => t.name === 'a_read')!.enabled).toBeUndefined(); expect(merged.find((t) => t.name === 'a_read')!.annotations).toEqual({ readOnlyHint: true }); }); diff --git a/packages/backend/src/adapters/mcp-adapter.util.ts b/packages/backend/src/adapters/mcp-adapter.util.ts index 9195e292..4ee58c08 100644 --- a/packages/backend/src/adapters/mcp-adapter.util.ts +++ b/packages/backend/src/adapters/mcp-adapter.util.ts @@ -32,7 +32,7 @@ export function mergeDiscoveredMcpTools( endpointMapping: d.endpointMapping, ...(d.outputSchema ? { outputSchema: d.outputSchema } : {}), ...(own?.annotations || d.annotations - ? { annotations: { ...(d.annotations ?? {}), ...(own?.annotations ?? {}) } } + ? { annotations: { ...(own?.annotations ?? {}), ...(d.annotations ?? {}) } } : {}), ...(own?.enabled === false ? { enabled: false } : {}), }; diff --git a/scripts/satellites/content/splunk-mcp-gateway/auth.md b/scripts/satellites/content/splunk-mcp-gateway/auth.md new file mode 100644 index 00000000..7a581eaa --- /dev/null +++ b/scripts/satellites/content/splunk-mcp-gateway/auth.md @@ -0,0 +1,8 @@ +The connector needs two values: + +| Variable | Where to find it | +|---|---| +| `SPLUNK_HOST` | The search head's host name, without `https://` and without a port, for example `yourstack.splunkcloud.com` | +| `SPLUNK_MCP_TOKEN` | Splunk MCP Server app → generate an **encrypted MCP token** for the user the AI acts as (shown once) | + +Before generating the token, a Splunk admin installs the Splunk MCP Server app (Splunkbase 7931), enables token authentication and gives that user's role the `mcp_tool_execute` capability. The token is sent as `Authorization: Bearer ` to `https://SPLUNK_HOST:8089/services/mcp`. At install, AnythingMCP lists the server's tools with it and then stores it encrypted. diff --git a/scripts/satellites/content/splunk-mcp-gateway/faq.md b/scripts/satellites/content/splunk-mcp-gateway/faq.md new file mode 100644 index 00000000..8e2f8da5 --- /dev/null +++ b/scripts/satellites/content/splunk-mcp-gateway/faq.md @@ -0,0 +1,14 @@ +### Splunk already has an MCP server. Why put AnythingMCP in front of it? +The Splunk MCP Server app provides the tools; AnythingMCP adds what a team needs around them: OAuth sign-in for Claude and ChatGPT (no token pasted into each laptop), roles that decide which people may call which tool, an audit log of every call in your own database, response mapping to drop fields before they reach the model, and Splunk next to your other systems behind one MCP endpoint. + +### Which tools do I get? +Exactly the ones your Splunk MCP Server lists. At install AnythingMCP asks your server for its tools, so a newer or older app version gives you its own set. Today that is SPL searches, saved searches, indexes, hosts and sources, knowledge objects, KV Store, users and, where Splunk AI Assistant is available, tools that write, explain and optimise SPL. After upgrading the app, click **Discover tools** on the connector. + +### Can the AI change data in Splunk? +SPL can write (`collect`, `outputlookup`, `delete`), which is why Splunk marks `splunk_run_query` and `splunk_run_saved_search` as able to change data. Give the token's user a role that cannot write to indexes or lookups. The two dashboard tools install switched off; turn them on per tool if you want the AI to create or edit dashboards. + +### Does it work with Splunk Cloud and Splunk Enterprise? +Yes, wherever the Splunk MCP Server app runs. On Splunk Cloud Platform the management port 8089 only answers addresses on your stack's search API allow list, so the address AnythingMCP calls from has to be added there. + +### Do I need to host anything? +No. Use AnythingMCP Cloud, or run the open-source AnythingMCP yourself (Docker) if the token should never leave your network. diff --git a/scripts/satellites/content/splunk-mcp-gateway/prompts.md b/scripts/satellites/content/splunk-mcp-gateway/prompts.md new file mode 100644 index 00000000..c5e15b0d --- /dev/null +++ b/scripts/satellites/content/splunk-mcp-gateway/prompts.md @@ -0,0 +1,9 @@ +- Which indexes do we have, and how much data is in each of them? +- Show me the hosts that sent data to the `main` index in the last 24 hours. +- Count failed logins per user in the last 7 days and list the top 10. +- Write the SPL for 5xx errors per service over the last hour, explain it, then run it. +- Which saved searches and alerts exist in the `search` app, and what do they look for? +- Run the saved search "Daily error summary" and summarise the result. +- Optimise this SPL: `index=web | stats count by status | where count > 100`. +- Which roles and capabilities does the user behind this connection have? +- Compare yesterday's error volume with the same day last week and tell me what changed. diff --git a/scripts/satellites/content/splunk-mcp-gateway/troubleshooting.md b/scripts/satellites/content/splunk-mcp-gateway/troubleshooting.md new file mode 100644 index 00000000..a1042881 --- /dev/null +++ b/scripts/satellites/content/splunk-mcp-gateway/troubleshooting.md @@ -0,0 +1,8 @@ +| Symptom | Cause and fix | +|---|---| +| `403` with "invalid token audience" | The token is an ordinary Splunk user or service token. Generate an encrypted token in the Splunk MCP Server app instead. | +| `403` on every tool | The user's role lacks `mcp_tool_execute`, or an admin disabled the tool in the app. | +| Install times out | Port 8089 is not reachable from AnythingMCP. On Splunk Cloud Platform add the calling address to the search API allow list (Admin Config Service); on Splunk Enterprise open the firewall. | +| TLS or certificate error | Splunk Enterprise still serves its default self-signed certificate on 8089. Install a certificate the AnythingMCP server trusts. | +| A tool is missing | Your app version does not have it, or it is switched off. Upgrade the app, then click **Discover tools** on the connector, and check the tool's toggle. | +| Searches return too much or time out | Give every search a time range (`earliest`, `latest`) and a row limit, and narrow the index. | diff --git a/scripts/satellites/readme.mjs b/scripts/satellites/readme.mjs index d382f34b..2246c9bf 100644 --- a/scripts/satellites/readme.mjs +++ b/scripts/satellites/readme.mjs @@ -220,7 +220,9 @@ function connectorReadme(sat, ctx) { const vars = a.requiredEnvVars ?? []; const writes = tools.filter((x) => access(x, a.connector?.type) === 'write').map((x) => x.name); const objects = sat.objects?.[lang] ?? sat.objects?.en ?? sat.about.split('. ').pop().replace(/\.$/, ''); - const title = `${sat.system} MCP Server`; + // `title` for a satellite in front of a vendor's own MCP server, so the repo + // is not named like the vendor's product (Splunk's app is "Splunk MCP Server"). + const title = sat.title ?? `${sat.system} MCP Server`; const introText = t.intro(title, tools.length, sat.system, lcFirst(objects), writes.length); const c = content[lang] ?? {}; const cEn = content.en ?? {}; @@ -707,9 +709,12 @@ function odataReadme(sat, ctx) { function parseRows(md) { if (!md) return []; - return md - .split('\n') - .filter((l) => l.startsWith('|') && !/^\|\s*-/.test(l) && !/^\|\s*Problem/.test(l)) + const lines = md.split('\n'); + const isSeparator = (l) => /^\|\s*:?-/.test(l ?? ''); + return lines + // Body rows only: not the separator, and not the header row above it, + // whatever it is called ("Problem", "Symptom", …). + .filter((l, i) => l.startsWith('|') && !isSeparator(l) && !isSeparator(lines[i + 1])) .map((l) => l.split('|').slice(1, -1).map((x) => x.trim())); } diff --git a/scripts/satellites/satellites.config.json b/scripts/satellites/satellites.config.json index b367ff55..124d123c 100644 --- a/scripts/satellites/satellites.config.json +++ b/scripts/satellites/satellites.config.json @@ -1387,6 +1387,43 @@ "how": "every tool called through a local AnythingMCP over MCP, 27 direct API cases incl. error paths, and Claude Code triaging English and German tickets through the connector" } }, + { + "repo": "splunk-mcp-gateway", + "owner": "keysersoft", + "type": "connector", + "system": "Splunk", + "title": "Splunk MCP Gateway", + "adapters": [ + "splunk" + ], + "wave": 1, + "about": "Splunk MCP server gateway: Splunk's official MCP Server in Claude & ChatGPT with OAuth, per-tool roles and audit. SPL and saved searches.", + "topics": [ + "splunk", + "splunk-cloud", + "splunk-mcp", + "spl", + "siem", + "log-analysis", + "security-operations" + ], + "website": "https://anythingmcp.com/guides/splunk-to-mcp", + "languages": [ + "en" + ], + "related": [ + "jev-mcp-server", + "openapi-to-mcp" + ], + "objects": { + "en": "SPL searches, saved searches, indexes, hosts and sources, knowledge objects and KV Store collections" + }, + "lastVerified": { + "date": "2026-10-02", + "against": "a production Splunk Cloud Platform stack with the Splunk MCP Server app", + "how": "bridged over MCP on AnythingMCP Cloud: tools/list returned 16 tools, splunk_get_indexes called from Claude Desktop through OAuth" + } + }, { "repo": "google-ads-mcp-server", "owner": "keysersoft", diff --git a/scripts/satellites/topic-counts.json b/scripts/satellites/topic-counts.json index de732adc..b77e4cb2 100644 --- a/scripts/satellites/topic-counts.json +++ b/scripts/satellites/topic-counts.json @@ -66,6 +66,7 @@ "legacy": 1792, "lightspeed": 63, "llm-tools": 6083, + "log-analysis": 2399, "magento": 2827, "magento2": 3914, "marketing-analytics": 1946, @@ -117,13 +118,19 @@ "sap-gateway": 6, "sap-hana": 258, "sap-s4hana": 39, + "security-operations": 692, "self-hosted": 37574, "selling-partner-api": 37, "shopware": 541, "shopware6": 321, + "siem": 3896, "soap": 1369, "soap-to-mcp": 1, "sp-api": 42, + "spl": 304, + "splunk": 2099, + "splunk-cloud": 9, + "splunk-mcp": 3, "sql": 86820, "sql-server": 10885, "sql-to-mcp": 0,