Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
22 changes: 21 additions & 1 deletion .github/workflows/python-interpreter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ jobs:
OWNER_LOWER=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
echo "image_name=ghcr.io/$OWNER_LOWER/afm-langchain-interpreter" >> $GITHUB_OUTPUT

- name: Build and push Docker image
- name: Build and push full image
uses: docker/build-push-action@v5
with:
context: python-interpreter
push: true
platforms: linux/amd64,linux/arm64
build-args: VARIANT=full
tags: |
${{ steps.meta.outputs.image_name }}:latest
${{ steps.meta.outputs.image_name }}:${{ github.sha }}
Expand All @@ -90,3 +91,22 @@ jobs:
annotations: |
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
index:org.opencontainers.image.licenses=Apache-2.0

- name: Build and push slim image
uses: docker/build-push-action@v5
with:
context: python-interpreter
push: true
platforms: linux/amd64,linux/arm64
build-args: VARIANT=slim
tags: |
${{ steps.meta.outputs.image_name }}:slim
${{ steps.meta.outputs.image_name }}:${{ github.sha }}-slim
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.title=AFM LangChain Interpreter (Slim)
org.opencontainers.image.licenses=Apache-2.0
annotations: |
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
index:org.opencontainers.image.licenses=Apache-2.0
35 changes: 28 additions & 7 deletions .github/workflows/release-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,23 @@ jobs:
# GHCR requires lowercase repository names
OWNER_LOWER=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
FULL_IMAGE="ghcr.io/$OWNER_LOWER/$IMAGE_NAME"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we change this variable name now that we have two variants of the Docker image, include a "full" one :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 49c15ed

TAGS="$FULL_IMAGE:v$VERSION"
if [ "$UPDATE_LATEST" = "true" ]; then
TAGS="$TAGS,$FULL_IMAGE:latest"
fi
echo "TAGS=$TAGS" >> $GITHUB_OUTPUT
TAGS_FULL="$FULL_IMAGE:v$VERSION"
[ "$UPDATE_LATEST" = "true" ] && TAGS_FULL="$TAGS_FULL,$FULL_IMAGE:latest"
echo "TAGS_FULL=$TAGS_FULL" >> $GITHUB_OUTPUT

TAGS_SLIM="$FULL_IMAGE:v$VERSION-slim"
[ "$UPDATE_LATEST" = "true" ] && TAGS_SLIM="$TAGS_SLIM,$FULL_IMAGE:slim"
echo "TAGS_SLIM=$TAGS_SLIM" >> $GITHUB_OUTPUT
echo "FULL_IMAGE=$FULL_IMAGE" >> $GITHUB_OUTPUT

- name: Build and push Docker image
- name: Build and push full image
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.docker-tags.outputs.TAGS }}
build-args: VARIANT=full
tags: ${{ steps.docker-tags.outputs.TAGS_FULL }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ inputs.version }}
Expand All @@ -81,6 +84,24 @@ jobs:
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
index:org.opencontainers.image.licenses=Apache-2.0

- name: Build and push slim image
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
push: true
platforms: linux/amd64,linux/arm64
build-args: VARIANT=slim
tags: ${{ steps.docker-tags.outputs.TAGS_SLIM }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ inputs.version }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.title=${{ inputs.image_title }} (Slim)
org.opencontainers.image.licenses=Apache-2.0
annotations: |
index:org.opencontainers.image.source=https://github.com/${{ github.repository }}
index:org.opencontainers.image.licenses=Apache-2.0

- name: Scan Docker image for vulnerabilities
uses: aquasecurity/trivy-action@0.34.0
with:
Expand Down
5 changes: 2 additions & 3 deletions ballerina-interpreter/agent.bal
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ function createAgent(AFMRecord afmRecord) returns ai:Agent|error {
if mcpServers is MCPServer[] {
foreach MCPServer mcpConn in mcpServers {
Transport transport = mcpConn.transport;
if transport.'type != "http" {
log:printWarn(string `Unsupported transport type: ${transport.'type}, only 'http' is supported`);
continue;
if transport is StdioTransport {
return error("Stdio transport is not yet supported by the Ballerina interpreter");
}

string[]? filteredTools = getFilteredTools(mcpConn.tool_filter);
Expand Down
38 changes: 32 additions & 6 deletions ballerina-interpreter/parser.bal
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,38 @@ function validateHttpVariables(AFMRecord afmRecord) returns error? {
}

Transport transport = server.transport;
if containsHttpVariable(transport.url) {
erroredKeys.push("tools.mcp.transport.url");
}

if authenticationContainsHttpVariable(transport.authentication) {
erroredKeys.push("tools.mcp.transport.authentication");
if transport is HttpTransport {
if containsHttpVariable(transport.url) {
erroredKeys.push("tools.mcp.transport.url");
}

if authenticationContainsHttpVariable(transport.authentication) {
erroredKeys.push("tools.mcp.transport.authentication");
}
} else {
if containsHttpVariable(transport.command) {
erroredKeys.push("tools.mcp.transport.command");
}

string[]? args = transport.args;
if args is string[] {
foreach string arg in args {
if containsHttpVariable(arg) {
erroredKeys.push("tools.mcp.transport.args");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess we could go one level down and include the arg?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 7f3763b

break;
}
}
}

map<string>? env = transport.env;
if env is map<string> {
foreach string val in env {
if containsHttpVariable(val) {
erroredKeys.push("tools.mcp.transport.env");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ditto.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 7f3763b

break;
}
}
}
}

if toolFilterContainsHttpVariable(server.tool_filter) {
Expand Down
14 changes: 12 additions & 2 deletions ballerina-interpreter/types.bal
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,25 @@ type Model record {|
|};

enum TransportType {
http
http,
stdio
}

type Transport record {|
type HttpTransport record {|
http 'type = http;
string url;
ClientAuthentication authentication?;
|};

type StdioTransport record {|
stdio 'type = stdio;
string command;
string[] args?;
map<string> env?;
|};

type Transport HttpTransport|StdioTransport;

type ClientAuthentication record {
string 'type;
};
Expand Down
15 changes: 15 additions & 0 deletions python-interpreter/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,22 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-editable --package afm-core --package afm-langchain

# Stage 2: Final image
# VARIANT=full installs Node.js, npm (npx), git, and uv/uvx for MCP server support.
# VARIANT=slim ships only Python + .venv.
FROM python:3.13-alpine
ARG VARIANT=full

RUN if [ "$VARIANT" = "full" ]; then \
apk add --no-cache nodejs npm git && \
echo "Full variant: nodejs, npm, git installed"; \
fi

# Install uv and uvx for Python-based MCP server support (full variant only)
COPY --from=ghcr.io/astral-sh/uv:0.10.0 /uv /uvx /uv-bins/
RUN if [ "$VARIANT" = "full" ]; then \
mv /uv-bins/uv /uv-bins/uvx /bin/; \
fi && \
rm -rf /uv-bins

# Set working directory
WORKDIR /app
Expand Down
6 changes: 3 additions & 3 deletions python-interpreter/packages/afm-cli/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "afm-cli"
version = "0.2.10"
version = "0.3.0"
description = "AFM CLI metapackage: installs afm-core and afm-langchain"
readme = "README.md"
classifiers = [
Expand All @@ -11,8 +11,8 @@ license = "Apache-2.0"
requires-python = ">=3.11"
urls = { Repository = "https://github.com/wso2/reference-implementations-afm" }
dependencies = [
"afm-core==0.1.7",
"afm-langchain>=0.1.0",
"afm-core==0.2.0",
"afm-langchain>=0.2.0",
]

[project.scripts]
Expand Down
2 changes: 1 addition & 1 deletion python-interpreter/packages/afm-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "afm-core"
version = "0.1.7"
version = "0.2.0"
description = "AFM (Agent-Flavored Markdown) core: parser, CLI, protocols, and interfaces"
readme = "README.md"
classifiers = [
Expand Down
8 changes: 7 additions & 1 deletion python-interpreter/packages/afm-core/src/afm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
)
from .models import (
ConsoleChatInterface,
HttpTransport,
WebChatInterface,
WebhookInterface,
)
Expand Down Expand Up @@ -232,7 +233,12 @@ def format_validation_output(afm: AFMRecord) -> str:
lines.append("")
lines.append(" MCP Servers:")
for server in afm.metadata.tools.mcp:
lines.append(f" - {server.name}: {server.transport.url}")
transport = server.transport
if isinstance(transport, HttpTransport):
transport_info = transport.url
else:
transport_info = transport.command
lines.append(f" - {server.name}: {transport_info}")
if server.tool_filter:
if server.tool_filter.allow:
lines.append(f" Allow: {', '.join(server.tool_filter.allow)}")
Expand Down
21 changes: 16 additions & 5 deletions python-interpreter/packages/afm-core/src/afm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,29 @@ class Model(BaseModel):
authentication: ClientAuthentication | None = None


class TransportType(str, Enum):
HTTP = "http"


class Transport(BaseModel):
class HttpTransport(BaseModel):
model_config = ConfigDict(extra="forbid")

type: Literal["http"] = "http"
url: str
authentication: ClientAuthentication | None = None


class StdioTransport(BaseModel):
model_config = ConfigDict(extra="forbid")

type: Literal["stdio"] = "stdio"
command: str
args: list[str] | None = None
env: dict[str, str] | None = None


Transport = Annotated[
HttpTransport | StdioTransport,
Field(discriminator="type"),
]


class ToolFilter(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand Down
13 changes: 9 additions & 4 deletions python-interpreter/packages/afm-core/src/afm/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from .exceptions import AFMValidationError, VariableResolutionError
from .models import (
ConsoleChatInterface,
HttpTransport,
WebChatInterface,
WebhookInterface,
)
Expand Down Expand Up @@ -178,10 +179,14 @@ def validate_http_variables(afm_record: AFMRecord) -> None:
for server in metadata.tools.mcp:
if contains_http_variable(server.name):
errored_fields.append("tools.mcp.name")
if contains_http_variable(server.transport.url):
errored_fields.append("tools.mcp.transport.url")
if _authentication_contains_http_variable(server.transport.authentication):
errored_fields.append("tools.mcp.transport.authentication")
# HTTP transport fields: url and authentication
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Self-explanatory, noh?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 49c15ed

if isinstance(server.transport, HttpTransport):
if contains_http_variable(server.transport.url):
errored_fields.append("tools.mcp.transport.url")
if _authentication_contains_http_variable(
server.transport.authentication
):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this go on one line for readability? Or is it too long?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, it was too long but I renamed the function and now its one line 49c15ed

errored_fields.append("tools.mcp.transport.authentication")
if _tool_filter_contains_http_variable(server.tool_filter):
errored_fields.append("tools.mcp.tool_filter")

Expand Down
5 changes: 5 additions & 0 deletions python-interpreter/packages/afm-core/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ def sample_minimal_path(fixtures_dir: Path) -> Path:
@pytest.fixture
def sample_no_frontmatter_path(fixtures_dir: Path) -> Path:
return fixtures_dir / "sample_no_frontmatter.afm.md"


@pytest.fixture
def sample_stdio_mcp_path(fixtures_dir: Path) -> Path:
return fixtures_dir / "sample_stdio_mcp_agent.afm.md"
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
spec_version: '0.3.0'
name: "StdioMcpAgent"
description: "A test agent with stdio MCP tools."
version: "1.0.0"
tools:
mcp:
- name: "filesystem_server"
transport:
type: stdio
command: "npx"
args:
- "-y"
- "@modelcontextprotocol/server-filesystem"
- "/tmp"
- name: "local_db_tool"
transport:
type: stdio
command: "python"
args:
- "server.py"
env:
DB_PATH: "./data.db"
API_KEY: "dummy-key"
tool_filter:
allow:
- "query"
- "search"
deny:
- "delete"
---
# Role
You are a file system and database assistant.

# Instructions
Use the available stdio tools to interact with the file system and database.
Loading
Loading