[PR #5647] feat: MCP system prompt variable substitution #5510

Open
opened 2026-06-05 15:21:34 -04:00 by yindo · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/Mintplex-Labs/anything-llm/pull/5647
Author: @pageragatz
Created: 5/17/2026
Status: 🔄 Open

Base: masterHead: claude/prompt-variable-substitution-8YeA5


📝 Commits (4)

  • 2b8767c feat: add {mcp..} variables to system prompts
  • df81d18 chore: ignore ContextWindowFinder test-cache outputs
  • ed6a87e feat: support positional args and nested variable refs in {mcp.*} variables
  • 0072935 refactor: consolidate MCP variable TTLs and tighten the resolver

📊 Changes

4 files changed (+524 additions, -1 deletions)

View changed files

📝 frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/index.jsx (+16 -1)
📝 server/.gitignore (+4 -0)
📝 server/__tests__/models/systemPromptVariables.test.js (+330 -0)
📝 server/models/systemPromptVariables.js (+174 -0)

📄 Description

Summary

Adds {mcp.<server>.<tool>} variable support to workspace system prompts so any running MCP server tool can be called inline at prompt-expansion time. Closes #5646.

  • New resolver in SystemPromptVariables.expandSystemPromptVariables: parses the key, looks up the MCP client via the existing MCPCompatibilityLayer singleton, calls the tool with no arguments, and substitutes the result string.
  • Boot-idempotent — calls bootMCPServers() lazily so the variable works on cold start even if no agent has been invoked yet.
  • TTL cache (30s) on successful results, negative cache (10s) on failures, and a 3s hard timeout per resolution so a broken or slow MCP server can never block or 500 a chat request — failures fall back to an empty string.
  • Frontend hint in Workspace → Chat Settings explains the new {mcp.<server>.<tool>} syntax next to the existing {time}, {date}, {datetime} examples.

How it works

Example: a workspace system prompt of

You are a helpful assistant. The currently playing track is: {mcp.deezer.lastfm\_get\_now\_playing}

…will, on every message, call the lastfm\_get\_now\_playing tool on the running deezer MCP server (configured in server/storage/plugins/anythingllm\_mcp\_servers.json) and inline the result before the prompt is sent to the LLM. Tool names that contain dots (e.g. mcp.foo.namespace.tool) work — only the first dot splits server from tool name.

The result string is extracted from the MCP callTool response by preferring concatenated text content blocks, falling back to JSON.stringify(result) if no text blocks are present.

Design notes

  • Lazy require of ../utils/MCP avoids a circular dependency at module load.
  • No tool arguments in this iteration — variable resolution always calls with arguments: {}. The intended use case is a parameterless "fetcher" tool whose output the LLM should see verbatim. Parameterised variables are a natural follow-up but out of scope here.
  • Single-pass expansion — resolved values are not re-scanned for variables, which prevents recursion attacks via MCP-returned strings.
  • Hard timeout uses Promise.race with the loser-timer cleared in finally so each successful resolution doesn't leak a 3s setTimeout into the event loop.
  • Fail-soft contract: any error path (server not running, tool throws, timeout, malformed key) resolves to "". The user sees an empty substitution rather than a chat error.

Test plan

Unit tests in server/\_\_tests\_\_/models/systemPromptVariables.test.js cover:

  • [x] Text-content extraction for a normal MCP tool result
  • [x] JSON fallback when no text content blocks are returned
  • [x] Empty-string fallback when the MCP server isn't running
  • [x] Empty-string fallback when the tool call throws
  • [x] Empty-string fallback when the tool call exceeds the 3s timeout (verified via jest.useFakeTimers)
  • [x] Malformed {mcp.foo} (no tool segment) → empty string
  • [x] Mixed expansion alongside {workspace.name} and static variables
  • [x] Cache verification: three resolutions of the same variable across two expandSystemPromptVariables calls result in exactly one underlying callTool (TTL cache works)
  • [x] Negative cache: three resolutions of a permanently-failing tool result in exactly one underlying callTool (failure path is also cached)
  • [x] Dotted tool names: {mcp.dotted-server.namespace.deep\_tool} calls callTool({ name: "namespace.deep\_tool", arguments: {} })

Manual smoke test:

  • [x] Configured a real deezer MCP server and verified that {mcp.deezer.lastfm\_get\_now\_playing} expands to a JSON Current track: {...} string in chat.
  • [x] Confirmed that with the MCP server stopped, the variable resolves to empty and the chat still works.

Files changed

  • server/models/systemPromptVariables.js — resolver, cache, timeout, extractor; new mcp. branch in expandSystemPromptVariables.
  • server/\_\_tests\_\_/models/systemPromptVariables.test.js — 10 new tests, shared-mock refactor.
  • frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/index.jsx — hint line explaining the new syntax.
  • server/.gitignore — ignore \_\_tests\_\_/utils/agents/models/ cache files generated by defaults.test.js.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/Mintplex-Labs/anything-llm/pull/5647 **Author:** [@pageragatz](https://github.com/pageragatz) **Created:** 5/17/2026 **Status:** 🔄 Open **Base:** `master` ← **Head:** `claude/prompt-variable-substitution-8YeA5` --- ### 📝 Commits (4) - [`2b8767c`](https://github.com/Mintplex-Labs/anything-llm/commit/2b8767c30a15ae5ddbe16f9bd39aef254c5139ca) feat: add {mcp.<server>.<tool>} variables to system prompts - [`df81d18`](https://github.com/Mintplex-Labs/anything-llm/commit/df81d18f79c47642c703fc8a98ae700e9b627d05) chore: ignore ContextWindowFinder test-cache outputs - [`ed6a87e`](https://github.com/Mintplex-Labs/anything-llm/commit/ed6a87eddd35a332475f0121687e4fb59e4eddeb) feat: support positional args and nested variable refs in {mcp.*} variables - [`0072935`](https://github.com/Mintplex-Labs/anything-llm/commit/0072935e10b18585902fb7c93a87c0fa282a3cbf) refactor: consolidate MCP variable TTLs and tighten the resolver ### 📊 Changes **4 files changed** (+524 additions, -1 deletions) <details> <summary>View changed files</summary> 📝 `frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/index.jsx` (+16 -1) 📝 `server/.gitignore` (+4 -0) 📝 `server/__tests__/models/systemPromptVariables.test.js` (+330 -0) 📝 `server/models/systemPromptVariables.js` (+174 -0) </details> ### 📄 Description ## Summary Adds `{mcp.<server>.<tool>}` variable support to workspace system prompts so any running MCP server tool can be called inline at prompt-expansion time. Closes #5646. * New resolver in `SystemPromptVariables.expandSystemPromptVariables`: parses the key, looks up the MCP client via the existing `MCPCompatibilityLayer` singleton, calls the tool with no arguments, and substitutes the result string. * Boot-idempotent — calls `bootMCPServers()` lazily so the variable works on cold start even if no agent has been invoked yet. * TTL cache (30s) on successful results, negative cache (10s) on failures, and a 3s hard timeout per resolution so a broken or slow MCP server can never block or 500 a chat request — failures fall back to an empty string. * Frontend hint in **Workspace → Chat Settings** explains the new `{mcp.<server>.<tool>}` syntax next to the existing `{time}`, `{date}`, `{datetime}` examples. ## How it works Example: a workspace system prompt of ``` You are a helpful assistant. The currently playing track is: {mcp.deezer.lastfm\_get\_now\_playing} ``` …will, on every message, call the `lastfm\_get\_now\_playing` tool on the running `deezer` MCP server (configured in `server/storage/plugins/anythingllm\_mcp\_servers.json`) and inline the result before the prompt is sent to the LLM. Tool names that contain dots (e.g. `mcp.foo.namespace.tool`) work — only the first dot splits server from tool name. The result string is extracted from the MCP `callTool` response by preferring concatenated `text` content blocks, falling back to `JSON.stringify(result)` if no text blocks are present. ## Design notes * **Lazy require** of `../utils/MCP` avoids a circular dependency at module load. * **No tool arguments** in this iteration — variable resolution always calls with `arguments: {}`. The intended use case is a parameterless "fetcher" tool whose output the LLM should see verbatim. Parameterised variables are a natural follow-up but out of scope here. * **Single-pass expansion** — resolved values are not re-scanned for variables, which prevents recursion attacks via MCP-returned strings. * **Hard timeout** uses `Promise.race` with the loser-timer cleared in `finally` so each successful resolution doesn't leak a 3s `setTimeout` into the event loop. * **Fail-soft contract**: any error path (server not running, tool throws, timeout, malformed key) resolves to `""`. The user sees an empty substitution rather than a chat error. ## Test plan Unit tests in `server/\_\_tests\_\_/models/systemPromptVariables.test.js` cover: * \[x] Text-content extraction for a normal MCP tool result * \[x] JSON fallback when no text content blocks are returned * \[x] Empty-string fallback when the MCP server isn't running * \[x] Empty-string fallback when the tool call throws * \[x] Empty-string fallback when the tool call exceeds the 3s timeout (verified via `jest.useFakeTimers`) * \[x] Malformed `{mcp.foo}` (no tool segment) → empty string * \[x] Mixed expansion alongside `{workspace.name}` and static variables * \[x] **Cache verification**: three resolutions of the same variable across two `expandSystemPromptVariables` calls result in exactly one underlying `callTool` (TTL cache works) * \[x] **Negative cache**: three resolutions of a permanently-failing tool result in exactly one underlying `callTool` (failure path is also cached) * \[x] **Dotted tool names**: `{mcp.dotted-server.namespace.deep\_tool}` calls `callTool({ name: "namespace.deep\_tool", arguments: {} })` Manual smoke test: * \[x] Configured a real `deezer` MCP server and verified that `{mcp.deezer.lastfm\_get\_now\_playing}` expands to a JSON `Current track: {...}` string in chat. * \[x] Confirmed that with the MCP server stopped, the variable resolves to empty and the chat still works. ## Files changed * `server/models/systemPromptVariables.js` — resolver, cache, timeout, extractor; new `mcp.` branch in `expandSystemPromptVariables`. * `server/\_\_tests\_\_/models/systemPromptVariables.test.js` — 10 new tests, shared-mock refactor. * `frontend/src/pages/WorkspaceSettings/ChatSettings/ChatPromptSettings/index.jsx` — hint line explaining the new syntax. * `server/.gitignore` — ignore `\_\_tests\_\_/utils/agents/models/` cache files generated by `defaults.test.js`. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
yindo added the pull-request label 2026-06-05 15:21:34 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Mintplex-Labs/anything-llm#5510