Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions docs/concepts/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ The 🔱 [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is a st

If not specified it uses the built-in [`mcp.yaml`](https://github.com/enola-dev/enola/blob/main/models/enola.dev/ai/mcp.yaml) by default.

The `command`, `args` & `env` are self-explanatory; `docs` is for a link to documentation.
For STDIO, the `command`, `args` & `env` are self-explanatory.

<!-- TODO Document type, url, headers, timeout - once that's tested to work... -->
For Streamable HTTP (Chunked Transfer), set `url:` - and possibly `headers:` e.g. with `Authorization: Bearer ${secret:XYZ}`. For Server-Sent Events (SSE), use `type: sse`.

<!-- TODO Document input, once that's implemented... -->
The `timeout` specifies Timeout duration, for both STDIO & HTTP; it defaults to _7 seconds._

The `docs` field is for a URL to link to documentation.

The boolean `roots` flag controls whether the current working directory is exposed; it defaults to false.

Expand Down Expand Up @@ -105,6 +107,18 @@ CAUTION: This server is inherently insecure; you should carefully evaluate if it

This needs `uvx` to be available; test if launching `uvx mcp-server-git` works, first.

### GitHub

```yaml
{% include "../../test/agents/github.agent.yaml" %}
```

Create a [secret](../use/secret/index.md) named `GITHUB_PAT` (GitHub Personal Access Token) [here](https://github.com/settings/personal-access-tokens/new).

```shell
enola ai --agents=test/agents/github.agent.yaml --in "How many stars do the top 3 repos that I own on GitHub repo have? (Use the GitHub context tool to find by GitHub user name.)"
```

### Memory

```yaml
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/other.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Other _"Graph Explorer"_ kind of UIs that we have heard about include:

Web-based:

* [Linkurious](https://linkurious.com) #Meo4j #Memgraph #ToDo
* [Linkurious](https://linkurious.com) #Neo4j #Memgraph #ToDo
* **[lodlive.it](http://en.lodlive.it/?https://w3id.org/italia/env/ld/place/municipality/00201_042002)** is #RDF #[opensource](https://github.com/LodLive/LodLive)
* [isSemantic's RDF Visualizer](https://issemantic.net/rdf-visualizer)
* [Triply's Yasgui](https://docs.triply.cc/yasgui-api/)
Expand Down
78 changes: 50 additions & 28 deletions java/dev/enola/ai/mcp/McpLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
*/
package dev.enola.ai.mcp;

import static dev.enola.ai.mcp.McpServerConnectionsConfig.ServerConnection.Type.*;
import static dev.enola.ai.mcp.McpServerConnectionsConfig.ServerConnection.Type.http;
import static dev.enola.ai.mcp.McpServerConnectionsConfig.ServerConnection.Type.stdio;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.MapMaker;
Expand All @@ -36,6 +37,7 @@
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
Expand All @@ -46,6 +48,7 @@

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -140,15 +143,17 @@ McpServerConnectionsConfig.ServerConnection replaceSecretPlaceholders(
private McpClientTransport createTransport(
McpServerConnectionsConfig.ServerConnection connectionConfig) {
var origin = connectionConfig.origin.toString();
if (!Strings.isNullOrEmpty(connectionConfig.url) && !sse.equals(connectionConfig.type))
connectionConfig.type = http;
switch (connectionConfig.type) {
case stdio -> {
var params = createStdIoServerParameters(connectionConfig);
var transport = new StdioClientTransport(params);
transport.setStdErrorHandler(new McpServerStdErrLogConsumer(origin));
return transport;
}
case http -> {
return createHttpClientSseClientTransport(connectionConfig);
case http, sse -> {
return createHttpTransport(connectionConfig);
}
default ->
throw new IllegalArgumentException(
Expand All @@ -169,9 +174,8 @@ private McpSyncClient createSyncClient(McpServerConnectionsConfig.ServerConnecti
McpClient.sync(transport)
.clientInfo(implementation)
.capabilities(capabilities.build())
// TODO Make this configurable - but how & from where?
// .initializationTimeout(Duration.ofSeconds(7))
// .requestTimeout(Duration.ofSeconds(7))
.initializationTimeout(config.timeout)
.requestTimeout(config.timeout)
.loggingConsumer(new McpServerLogConsumer(origin))
.build();

Expand All @@ -196,21 +200,27 @@ private McpSyncClient createSyncClient(McpServerConnectionsConfig.ServerConnecti
return client;
}

// TODO Inline (again) into above?

private ServerParameters createStdIoServerParameters(ServerConnection connectionConfig) {
if (connectionConfig.type != stdio) throw new IllegalArgumentException();
return ServerParameters.builder(connectionConfig.command)
.args(connectionConfig.args)
.env(connectionConfig.env)
.build();
}

private HttpClientSseClientTransport createHttpClientSseClientTransport(
ServerConnection connectionConfig) {
if (connectionConfig.type != http) throw new IllegalArgumentException();
// TODO Set headers, timeout etc.
return HttpClientSseClientTransport.builder(connectionConfig.url).build();
private McpClientTransport createHttpTransport(ServerConnection connectionConfig) {
var requestBuilder = HttpRequest.newBuilder();
connectionConfig.headers.forEach(requestBuilder::header);
if (connectionConfig.type == http) {
var transportBuilder = HttpClientStreamableHttpTransport.builder(connectionConfig.url);
transportBuilder.requestBuilder(requestBuilder);
transportBuilder.connectTimeout(connectionConfig.timeout);
return transportBuilder.build();
} else { // SSE
var transportBuilder = HttpClientSseClientTransport.builder(connectionConfig.url);
transportBuilder.requestBuilder(requestBuilder);
transportBuilder.connectTimeout(connectionConfig.timeout);
return transportBuilder.build();
}
}

private Map<String, String> replaceSecretPlaceholders(Map<String, String> map)
Expand All @@ -237,19 +247,31 @@ private List<String> replaceSecretPlaceholders(List<String> list) throws IOExcep
}

private String replaceSecretPlaceholders(String value) throws IOException {
final String prefix = "${secret:";
final String suffix = "}";
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return value;

var nameStartIndex = prefix.length();
var nameEndIndex = value.length() - suffix.length();
if (nameStartIndex >= nameEndIndex)
throw new IOException("Invalid secret placeholder: " + value);
var secretName = value.substring(nameStartIndex, nameEndIndex);

return secretManager
.getOptional(secretName)
.map(secret -> secret.map(String::new))
.orElseThrow(() -> new IOException("Secret not found: " + value));
final var prefix = "${secret:";
final var suffix = '}';
if (value.contains("${") && !value.contains(prefix))
throw new IOException("Invalid secret placeholder; must be ${secret:XYZ}: " + value);
if (!value.contains(prefix)) return value;

int placeholderStart = value.indexOf(prefix);
if (placeholderStart == -1) {
return value;
}

int nameEnd = value.indexOf(suffix, placeholderStart);
if (nameEnd == -1) {
throw new IOException("Invalid secret placeholder, missing " + suffix + ": " + value);
}

int nameStart = placeholderStart + prefix.length();
var secretName = value.substring(nameStart, nameEnd);

var secretValue =
secretManager
.getOptional(secretName)
.map(secretOpt -> secretOpt.map(String::new))
.orElseThrow(() -> new IOException("Secret not found: " + secretName));

return value.substring(0, placeholderStart) + secretValue + value.substring(nameEnd + 1);
}
Comment on lines 249 to 276
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of replaceSecretPlaceholders only replaces the first secret placeholder found in the input string. If a string contains multiple placeholders (e.g., "key1=${secret:A}&key2=${secret:B}"), subsequent placeholders will not be replaced. The implementation should be updated to iteratively replace all occurrences of secret placeholders.

    private String replaceSecretPlaceholders(String value) throws IOException {
        final var prefix = "${secret:";
        final var suffix = '}';
        if (value.contains("${") && !value.contains(prefix)) {
            throw new IOException("Invalid secret placeholder; must be ${secret:XYZ}: " + value);
        }
        if (!value.contains(prefix)) {
            return value;
        }

        final var result = new StringBuilder(value.length());
        int lastIndex = 0;
        int placeholderStart;
        while ((placeholderStart = value.indexOf(prefix, lastIndex)) != -1) {
            result.append(value, lastIndex, placeholderStart);

            int nameEnd = value.indexOf(suffix, placeholderStart);
            if (nameEnd == -1) {
                throw new IOException("Invalid secret placeholder, missing " + suffix + ": " + value);
            }

            int nameStart = placeholderStart + prefix.length();
            var secretName = value.substring(nameStart, nameEnd);

            var secretValue =
                    secretManager
                            .getOptional(secretName)
                            .map(secretOpt -> secretOpt.map(String::new))
                            .orElseThrow(() -> new IOException("Secret not found: " + secretName));

            result.append(secretValue);
            lastIndex = nameEnd + 1;
        }
        result.append(value.substring(lastIndex));
        return result.toString();
    }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are correct, of course, but this shouldn't be required; I'll keep it as-is, for now - just for simplicity.

}
12 changes: 12 additions & 0 deletions java/dev/enola/ai/mcp/McpLoaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public class McpLoaderTest {
public McpLoaderTest() throws IOException {
sm = new InMemorySecretManager();
sm.store("BRAVE_API_KEY", SECRET.toCharArray());
sm.store("GITHUB_PAT", SECRET.toCharArray());
}

@Test
Expand Down Expand Up @@ -86,6 +87,17 @@ public void secrets() throws IOException {
assertThat(key2).isEqualTo(SECRET);
}

@Test
public void secretsContainsNotStartsWith() throws IOException {
var r = new ClasspathResource("enola.dev/ai/mcp.yaml");
var loader = new McpLoader(sm);
var config = loader.loadAndReturn(r);
var serverConfig = config.servers.get("github");
var serverConfig2 = loader.replaceSecretPlaceholders(serverConfig);
var key2 = serverConfig2.headers.get("Authorization");
assertThat(key2).isEqualTo("Bearer " + SECRET);
}

@Test
public void secretNotAvailable() throws IOException {
var r = new ClasspathResource("enola.dev/ai/mcp.yaml");
Expand Down
5 changes: 3 additions & 2 deletions java/dev/enola/ai/mcp/McpServerConnectionsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ public ServerConnection(ServerConnection other) {

public enum Type {
stdio,
http, // TODO http_sse? streamable_http?
http,
sse
}

public Type type = Type.stdio;
Expand All @@ -99,7 +100,7 @@ public enum Type {
// HTTP; like com.google.adk.tools.mcp.SseServerParameters
public String url;
public Map<String, String> headers = new HashMap<>();
public Duration timeout;
public Duration timeout = Duration.ofSeconds(7);

public boolean roots = false;

Expand Down
6 changes: 6 additions & 0 deletions models/enola.dev/ai/mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ servers:
docs: https://github.com/brave/brave-search-mcp-server
env:
BRAVE_API_KEY: ${secret:BRAVE_API_KEY}

github:
docs: https://github.com/github/github-mcp-server
url: https://api.githubcopilot.com/mcp/
headers:
Authorization: Bearer ${secret:GITHUB_PAT}
4 changes: 4 additions & 0 deletions test/agents/github.agent.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
$schema: https://enola.dev/ai/agent
model: google://?model=gemini-2.5-flash
tools:
- github
Loading