Skip to content

Commit 87cbbd1

Browse files
authored
Merge pull request #10 from RadCod3/feat/langchain-stdio-support
Add stdio mcp tool support to python-interpreter
2 parents 815af5e + 7f3763b commit 87cbbd1

32 files changed

+1165
-345
lines changed

.github/workflows/python-interpreter.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,13 @@ jobs:
7373
OWNER_LOWER=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
7474
echo "image_name=ghcr.io/$OWNER_LOWER/afm-langchain-interpreter" >> $GITHUB_OUTPUT
7575
76-
- name: Build and push Docker image
76+
- name: Build and push full image
7777
uses: docker/build-push-action@v5
7878
with:
7979
context: python-interpreter
8080
push: true
8181
platforms: linux/amd64,linux/arm64
82+
build-args: VARIANT=full
8283
tags: |
8384
${{ steps.meta.outputs.image_name }}:latest
8485
${{ steps.meta.outputs.image_name }}:${{ github.sha }}
@@ -90,3 +91,22 @@ jobs:
9091
annotations: |
9192
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
9293
index:org.opencontainers.image.licenses=Apache-2.0
94+
95+
- name: Build and push slim image
96+
uses: docker/build-push-action@v5
97+
with:
98+
context: python-interpreter
99+
push: true
100+
platforms: linux/amd64,linux/arm64
101+
build-args: VARIANT=slim
102+
tags: |
103+
${{ steps.meta.outputs.image_name }}:slim
104+
${{ steps.meta.outputs.image_name }}:${{ github.sha }}-slim
105+
labels: |
106+
org.opencontainers.image.source=https://github.com/${{ github.repository }}
107+
org.opencontainers.image.revision=${{ github.sha }}
108+
org.opencontainers.image.title=AFM LangChain Interpreter (Slim)
109+
org.opencontainers.image.licenses=Apache-2.0
110+
annotations: |
111+
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
112+
index:org.opencontainers.image.licenses=Apache-2.0

.github/workflows/release-ballerina.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ jobs:
139139

140140
bump-version:
141141
needs: [validate, finalize]
142+
if: false # Disabled: direct push blocked by branch protection
142143
runs-on: ubuntu-latest
143144
permissions:
144145
contents: write

.github/workflows/release-docker.yml

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ on:
2323
description: "Human-readable image title for OCI labels (e.g., AFM Ballerina Interpreter)"
2424
required: true
2525
type: string
26+
build_slim:
27+
description: "Whether to build and push a slim image variant"
28+
required: false
29+
default: false
30+
type: boolean
2631

2732
jobs:
2833
docker:
@@ -56,21 +61,24 @@ jobs:
5661
run: |
5762
# GHCR requires lowercase repository names
5863
OWNER_LOWER=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
59-
FULL_IMAGE="ghcr.io/$OWNER_LOWER/$IMAGE_NAME"
60-
TAGS="$FULL_IMAGE:v$VERSION"
61-
if [ "$UPDATE_LATEST" = "true" ]; then
62-
TAGS="$TAGS,$FULL_IMAGE:latest"
63-
fi
64-
echo "TAGS=$TAGS" >> $GITHUB_OUTPUT
65-
echo "FULL_IMAGE=$FULL_IMAGE" >> $GITHUB_OUTPUT
64+
BASE_IMAGE="ghcr.io/$OWNER_LOWER/$IMAGE_NAME"
65+
TAGS_FULL="$BASE_IMAGE:v$VERSION"
66+
[ "$UPDATE_LATEST" = "true" ] && TAGS_FULL="$TAGS_FULL,$BASE_IMAGE:latest"
67+
echo "TAGS_FULL=$TAGS_FULL" >> $GITHUB_OUTPUT
6668
67-
- name: Build and push Docker image
69+
TAGS_SLIM="$BASE_IMAGE:v$VERSION-slim"
70+
[ "$UPDATE_LATEST" = "true" ] && TAGS_SLIM="$TAGS_SLIM,$BASE_IMAGE:slim"
71+
echo "TAGS_SLIM=$TAGS_SLIM" >> $GITHUB_OUTPUT
72+
echo "BASE_IMAGE=$BASE_IMAGE" >> $GITHUB_OUTPUT
73+
74+
- name: Build and push full image
6875
uses: docker/build-push-action@v5
6976
with:
7077
context: ${{ inputs.context }}
7178
push: true
7279
platforms: linux/amd64,linux/arm64
73-
tags: ${{ steps.docker-tags.outputs.TAGS }}
80+
build-args: VARIANT=full
81+
tags: ${{ steps.docker-tags.outputs.TAGS_FULL }}
7482
labels: |
7583
org.opencontainers.image.source=https://github.com/${{ github.repository }}
7684
org.opencontainers.image.version=${{ inputs.version }}
@@ -81,18 +89,56 @@ jobs:
8189
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
8290
index:org.opencontainers.image.licenses=Apache-2.0
8391
84-
- name: Scan Docker image for vulnerabilities
92+
- name: Build and push slim image
93+
if: ${{ inputs.build_slim }}
94+
uses: docker/build-push-action@v5
95+
with:
96+
context: ${{ inputs.context }}
97+
push: true
98+
platforms: linux/amd64,linux/arm64
99+
build-args: VARIANT=slim
100+
tags: ${{ steps.docker-tags.outputs.TAGS_SLIM }}
101+
labels: |
102+
org.opencontainers.image.source=https://github.com/${{ github.repository }}
103+
org.opencontainers.image.version=${{ inputs.version }}
104+
org.opencontainers.image.revision=${{ github.sha }}
105+
org.opencontainers.image.title=${{ inputs.image_title }} (Slim)
106+
org.opencontainers.image.licenses=Apache-2.0
107+
annotations: |
108+
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
109+
index:org.opencontainers.image.licenses=Apache-2.0
110+
111+
- name: Scan full Docker image for vulnerabilities
85112
uses: aquasecurity/trivy-action@0.34.0
86113
with:
87-
image-ref: ${{ steps.docker-tags.outputs.FULL_IMAGE }}:v${{ inputs.version }}
114+
image-ref: ${{ steps.docker-tags.outputs.BASE_IMAGE }}:v${{ inputs.version }}
88115
format: "sarif"
89-
output: "trivy-results.sarif"
116+
output: "trivy-results-full.sarif"
90117
severity: "CRITICAL,HIGH"
91118
limit-severities-for-sarif: true
92119
exit-code: "1"
93120

94-
- name: Upload Trivy scan results to GitHub Security tab
121+
- name: Upload full image Trivy scan results to GitHub Security tab
95122
uses: github/codeql-action/upload-sarif@v4
96123
if: always()
97124
with:
98-
sarif_file: "trivy-results.sarif"
125+
sarif_file: "trivy-results-full.sarif"
126+
category: "trivy-full-${{ inputs.image_name }}"
127+
128+
- name: Scan slim Docker image for vulnerabilities
129+
if: ${{ always() && inputs.build_slim }}
130+
uses: aquasecurity/trivy-action@0.34.0
131+
with:
132+
image-ref: ${{ steps.docker-tags.outputs.BASE_IMAGE }}:v${{ inputs.version }}-slim
133+
format: "sarif"
134+
output: "trivy-results-slim.sarif"
135+
severity: "CRITICAL,HIGH"
136+
limit-severities-for-sarif: true
137+
exit-code: "1"
138+
139+
- name: Upload slim image Trivy scan results to GitHub Security tab
140+
uses: github/codeql-action/upload-sarif@v4
141+
if: ${{ always() && inputs.build_slim }}
142+
with:
143+
sarif_file: "trivy-results-slim.sarif"
144+
category: "trivy-slim-${{ inputs.image_name }}"

.github/workflows/release-python.yml

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,22 @@ on:
44
workflow_dispatch:
55
inputs:
66
package:
7-
description: 'Python package to release'
7+
description: "Python package to release"
88
required: true
99
type: choice
1010
options:
1111
- afm-core
1212
- afm-langchain
1313
branch:
14-
description: 'Branch to release from'
14+
description: "Branch to release from"
1515
required: false
16-
default: 'main'
16+
default: "main"
1717
type: string
18+
skip_pypi:
19+
description: "Skip PyPI publishing"
20+
required: false
21+
default: false
22+
type: boolean
1823

1924
concurrency:
2025
group: release-python-interpreter
@@ -121,11 +126,12 @@ jobs:
121126
pypi-publish:
122127
needs: [validate, test, docker]
123128
if: >-
124-
!cancelled()
125-
&& needs.validate.result == 'success'
126-
&& needs.test.result == 'success'
127-
&& (needs.docker.result == 'success'
128-
|| needs.docker.result == 'skipped')
129+
!cancelled()
130+
&& !inputs.skip_pypi
131+
&& needs.validate.result == 'success'
132+
&& needs.test.result == 'success'
133+
&& (needs.docker.result == 'success'
134+
|| needs.docker.result == 'skipped')
129135
runs-on: ubuntu-latest
130136
steps:
131137
- name: Checkout repository
@@ -181,18 +187,20 @@ jobs:
181187
version: ${{ needs.validate.outputs.release_version }}
182188
branch: ${{ inputs.branch }}
183189
image_title: AFM LangChain Interpreter
190+
build_slim: true
184191
permissions:
185192
packages: write
186193
security-events: write
187194

188195
finalize:
189196
needs: [validate, pypi-publish, docker]
190197
if: >-
191-
!cancelled()
192-
&& needs.validate.result == 'success'
193-
&& needs.pypi-publish.result == 'success'
194-
&& (needs.docker.result == 'success'
195-
|| needs.docker.result == 'skipped')
198+
!cancelled()
199+
&& needs.validate.result == 'success'
200+
&& (needs.pypi-publish.result == 'success'
201+
|| (needs.pypi-publish.result == 'skipped' && inputs.skip_pypi))
202+
&& (needs.docker.result == 'success'
203+
|| needs.docker.result == 'skipped')
196204
uses: ./.github/workflows/release-finalize.yml
197205
with:
198206
tag: ${{ needs.validate.outputs.tag }}
@@ -206,10 +214,7 @@ jobs:
206214

207215
bump-version:
208216
needs: [validate, finalize]
209-
if: >-
210-
!cancelled()
211-
&& needs.validate.result == 'success'
212-
&& needs.finalize.result == 'success'
217+
if: false # Disabled: direct push blocked by branch protection
213218
runs-on: ubuntu-latest
214219
permissions:
215220
contents: write

ballerina-interpreter/agent.bal

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,8 @@ function createAgent(AFMRecord afmRecord) returns ai:Agent|error {
3131
if mcpServers is MCPServer[] {
3232
foreach MCPServer mcpConn in mcpServers {
3333
Transport transport = mcpConn.transport;
34-
if transport.'type != "http" {
35-
log:printWarn(string `Unsupported transport type: ${transport.'type}, only 'http' is supported`);
36-
continue;
34+
if transport is StdioTransport {
35+
return error("Stdio transport is not yet supported by the Ballerina interpreter");
3736
}
3837

3938
string[]? filteredTools = getFilteredTools(mcpConn.tool_filter);

ballerina-interpreter/parser.bal

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,36 @@ function validateHttpVariables(AFMRecord afmRecord) returns error? {
246246
}
247247

248248
Transport transport = server.transport;
249-
if containsHttpVariable(transport.url) {
250-
erroredKeys.push("tools.mcp.transport.url");
251-
}
252-
253-
if authenticationContainsHttpVariable(transport.authentication) {
254-
erroredKeys.push("tools.mcp.transport.authentication");
249+
if transport is HttpTransport {
250+
if containsHttpVariable(transport.url) {
251+
erroredKeys.push("tools.mcp.transport.url");
252+
}
253+
254+
if authenticationContainsHttpVariable(transport.authentication) {
255+
erroredKeys.push("tools.mcp.transport.authentication");
256+
}
257+
} else {
258+
if containsHttpVariable(transport.command) {
259+
erroredKeys.push("tools.mcp.transport.command");
260+
}
261+
262+
string[]? args = transport.args;
263+
if args is string[] {
264+
foreach int idx in 0 ..< args.length() {
265+
if containsHttpVariable(args[idx]) {
266+
erroredKeys.push(string `tools.mcp.transport.args[${idx}]`);
267+
}
268+
}
269+
}
270+
271+
map<string>? env = transport.env;
272+
if env is map<string> {
273+
foreach [string, string] [k, val] in env.entries() {
274+
if containsHttpVariable(val) {
275+
erroredKeys.push("tools.mcp.transport.env." + k);
276+
}
277+
}
278+
}
255279
}
256280

257281
if toolFilterContainsHttpVariable(server.tool_filter) {

ballerina-interpreter/tests/main_test.bal

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,75 @@ function testContainsHttpVariable() {
181181
test:assertFalse(containsHttpVariable("no variables here"));
182182
}
183183

184+
@test:Config
185+
function testValidateHttpVariablesInStdioTransportArgs() {
186+
AFMRecord afmRecord = {
187+
metadata: {
188+
spec_version: "0.3.0",
189+
tools: {
190+
mcp: [
191+
{
192+
name: "test-server",
193+
transport: <StdioTransport>{
194+
'type: stdio,
195+
command: "some-command",
196+
args: ["${http:payload.field}", "--safe-arg", "${http:header.auth}"]
197+
}
198+
}
199+
]
200+
}
201+
},
202+
role: "",
203+
instructions: ""
204+
};
205+
206+
error? result = validateHttpVariables(afmRecord);
207+
if result is () {
208+
test:assertFail("Expected error for http: variables in stdio transport args");
209+
}
210+
test:assertTrue(result.message().includes("tools.mcp.transport.args[0]"),
211+
"Expected error to include 'tools.mcp.transport.args[0]'");
212+
test:assertTrue(result.message().includes("tools.mcp.transport.args[2]"),
213+
"Expected error to include 'tools.mcp.transport.args[2]'");
214+
test:assertFalse(result.message().includes("tools.mcp.transport.args[1]"),
215+
"Expected error NOT to include 'tools.mcp.transport.args[1]' (clean arg)");
216+
}
217+
218+
@test:Config
219+
function testValidateHttpVariablesInStdioTransportEnv() {
220+
AFMRecord afmRecord = {
221+
metadata: {
222+
spec_version: "0.3.0",
223+
tools: {
224+
mcp: [
225+
{
226+
name: "test-server",
227+
transport: <StdioTransport>{
228+
'type: stdio,
229+
command: "some-command",
230+
env: {
231+
"CLEAN_VAR": "safe-value",
232+
"SECRET_KEY": "${http:header.Authorization}"
233+
}
234+
}
235+
}
236+
]
237+
}
238+
},
239+
role: "",
240+
instructions: ""
241+
};
242+
243+
error? result = validateHttpVariables(afmRecord);
244+
if result is () {
245+
test:assertFail("Expected error for http: variables in stdio transport env");
246+
}
247+
test:assertTrue(result.message().includes("tools.mcp.transport.env.SECRET_KEY"),
248+
"Expected error to include 'tools.mcp.transport.env.SECRET_KEY'");
249+
test:assertFalse(result.message().includes("tools.mcp.transport.env.CLEAN_VAR"),
250+
"Expected error NOT to include 'tools.mcp.transport.env.CLEAN_VAR' (clean env var)");
251+
}
252+
184253
@test:Config
185254
function testParseAfmWithoutFrontmatter() {
186255
string content = string `# Role

ballerina-interpreter/types.bal

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,25 @@ type Model record {|
2727
|};
2828

2929
enum TransportType {
30-
http
30+
http,
31+
stdio
3132
}
3233

33-
type Transport record {|
34+
type HttpTransport record {|
3435
http 'type = http;
3536
string url;
3637
ClientAuthentication authentication?;
3738
|};
3839

40+
type StdioTransport record {|
41+
stdio 'type = stdio;
42+
string command;
43+
string[] args?;
44+
map<string> env?;
45+
|};
46+
47+
type Transport HttpTransport|StdioTransport;
48+
3949
type ClientAuthentication record {
4050
string 'type;
4151
};

0 commit comments

Comments
 (0)