From 3bf97a6f9e4df4d37aad66a795ccecf294015eab Mon Sep 17 00:00:00 2001 From: Michael Vorburger Date: Mon, 22 Sep 2025 21:06:41 +0200 Subject: [PATCH] feat: GitHub MCP server via HTTP instead of STDIO (fixes #1733) --- docs/concepts/mcp.md | 20 ++++- docs/concepts/other.md | 2 +- java/dev/enola/ai/mcp/McpLoader.java | 78 ++++++++++++------- java/dev/enola/ai/mcp/McpLoaderTest.java | 12 +++ .../ai/mcp/McpServerConnectionsConfig.java | 5 +- models/enola.dev/ai/mcp.yaml | 6 ++ test/agents/github.agent.yaml | 4 + 7 files changed, 93 insertions(+), 34 deletions(-) create mode 100644 test/agents/github.agent.yaml diff --git a/docs/concepts/mcp.md b/docs/concepts/mcp.md index 8d5d5417f..edb91b09c 100644 --- a/docs/concepts/mcp.md +++ b/docs/concepts/mcp.md @@ -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. - +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`. - +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. @@ -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 diff --git a/docs/concepts/other.md b/docs/concepts/other.md index 992fb3e8a..4c2109b99 100644 --- a/docs/concepts/other.md +++ b/docs/concepts/other.md @@ -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/) diff --git a/java/dev/enola/ai/mcp/McpLoader.java b/java/dev/enola/ai/mcp/McpLoader.java index 0604931ab..36ecba79f 100644 --- a/java/dev/enola/ai/mcp/McpLoader.java +++ b/java/dev/enola/ai/mcp/McpLoader.java @@ -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; @@ -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; @@ -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; @@ -140,6 +143,8 @@ 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); @@ -147,8 +152,8 @@ private McpClientTransport createTransport( transport.setStdErrorHandler(new McpServerStdErrLogConsumer(origin)); return transport; } - case http -> { - return createHttpClientSseClientTransport(connectionConfig); + case http, sse -> { + return createHttpTransport(connectionConfig); } default -> throw new IllegalArgumentException( @@ -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(); @@ -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 replaceSecretPlaceholders(Map map) @@ -237,19 +247,31 @@ private List replaceSecretPlaceholders(List 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); } } diff --git a/java/dev/enola/ai/mcp/McpLoaderTest.java b/java/dev/enola/ai/mcp/McpLoaderTest.java index 03d24a320..49a0d6002 100644 --- a/java/dev/enola/ai/mcp/McpLoaderTest.java +++ b/java/dev/enola/ai/mcp/McpLoaderTest.java @@ -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 @@ -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"); diff --git a/java/dev/enola/ai/mcp/McpServerConnectionsConfig.java b/java/dev/enola/ai/mcp/McpServerConnectionsConfig.java index 9b378b30d..eedceec1a 100644 --- a/java/dev/enola/ai/mcp/McpServerConnectionsConfig.java +++ b/java/dev/enola/ai/mcp/McpServerConnectionsConfig.java @@ -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; @@ -99,7 +100,7 @@ public enum Type { // HTTP; like com.google.adk.tools.mcp.SseServerParameters public String url; public Map headers = new HashMap<>(); - public Duration timeout; + public Duration timeout = Duration.ofSeconds(7); public boolean roots = false; diff --git a/models/enola.dev/ai/mcp.yaml b/models/enola.dev/ai/mcp.yaml index eb8262302..de51d1d0c 100644 --- a/models/enola.dev/ai/mcp.yaml +++ b/models/enola.dev/ai/mcp.yaml @@ -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} diff --git a/test/agents/github.agent.yaml b/test/agents/github.agent.yaml new file mode 100644 index 000000000..842f3e67c --- /dev/null +++ b/test/agents/github.agent.yaml @@ -0,0 +1,4 @@ +$schema: https://enola.dev/ai/agent +model: google://?model=gemini-2.5-flash +tools: + - github