From fed87364d3714a649b79aeb4d2e74d07f0eecfc6 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Fri, 26 Jun 2026 09:03:51 +0000 Subject: [PATCH 1/2] MCP Server component: - Initial implemation --- .gitlab-ci.yml | 1 + deploy/all.sh | 4 + manifests/mcp_serverservice.yaml | 105 ++ my_deploy.sh | 4 + scripts/show_logs_mcp_server.sh | 27 + src/common/Constants.py | 3 + src/mcp_server/.gitlab-ci.yml | 77 ++ src/mcp_server/Config.py | 71 ++ src/mcp_server/Dockerfile | 32 + src/mcp_server/__init__.py | 14 + src/mcp_server/requirements.in | 23 + src/mcp_server/service/App.py | 101 ++ src/mcp_server/service/Dependencies.py | 34 + src/mcp_server/service/McpServer.py | 396 +++++++ src/mcp_server/service/MockStore.py | 506 +++++++++ src/mcp_server/service/__init__.py | 17 + src/mcp_server/service/tools/Base.py | 48 + src/mcp_server/service/tools/Custom.py | 92 ++ src/mcp_server/service/tools/Definitions.py | 1008 +++++++++++++++++ .../service/tools/OpticalAllocation.py | 78 ++ src/mcp_server/service/tools/Registry.py | 252 +++++ src/mcp_server/service/tools/__init__.py | 13 + src/mcp_server/tests/__init__.py | 13 + .../tests/fixtures/dummy_contexts.json | 119 ++ src/mcp_server/tests/test_unitary.py | 202 ++++ 25 files changed, 3240 insertions(+) create mode 100644 manifests/mcp_serverservice.yaml create mode 100755 scripts/show_logs_mcp_server.sh create mode 100644 src/mcp_server/.gitlab-ci.yml create mode 100644 src/mcp_server/Config.py create mode 100644 src/mcp_server/Dockerfile create mode 100644 src/mcp_server/__init__.py create mode 100644 src/mcp_server/requirements.in create mode 100644 src/mcp_server/service/App.py create mode 100644 src/mcp_server/service/Dependencies.py create mode 100644 src/mcp_server/service/McpServer.py create mode 100644 src/mcp_server/service/MockStore.py create mode 100644 src/mcp_server/service/__init__.py create mode 100644 src/mcp_server/service/tools/Base.py create mode 100644 src/mcp_server/service/tools/Custom.py create mode 100644 src/mcp_server/service/tools/Definitions.py create mode 100644 src/mcp_server/service/tools/OpticalAllocation.py create mode 100644 src/mcp_server/service/tools/Registry.py create mode 100644 src/mcp_server/service/tools/__init__.py create mode 100644 src/mcp_server/tests/__init__.py create mode 100644 src/mcp_server/tests/fixtures/dummy_contexts.json create mode 100644 src/mcp_server/tests/test_unitary.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fc1408b09..1b225a0d2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,6 +37,7 @@ include: #- local: '/manifests/.gitlab-ci.yml' - local: '/src/monitoring/.gitlab-ci.yml' - local: '/src/nbi/.gitlab-ci.yml' + - local: '/src/mcp_server/.gitlab-ci.yml' - local: '/src/context/.gitlab-ci.yml' - local: '/src/device/.gitlab-ci.yml' - local: '/src/service/.gitlab-ci.yml' diff --git a/deploy/all.sh b/deploy/all.sh index ca0beea31..8ce597ce3 100755 --- a/deploy/all.sh +++ b/deploy/all.sh @@ -72,6 +72,10 @@ export TFS_COMPONENTS=${TFS_COMPONENTS:-"context device pathcomp service slice n # Uncomment to activate Pluggables Component #export TFS_COMPONENTS="${TFS_COMPONENTS} pluggables" +# Uncomment to activate MCP Server. +# Keep it last so it is deployed after optional components it can expose. +#export TFS_COMPONENTS="${TFS_COMPONENTS} mcp_server" + # If not already set, set the tag you want to use for your images. export TFS_IMAGE_TAG=${TFS_IMAGE_TAG:-"dev"} diff --git a/manifests/mcp_serverservice.yaml b/manifests/mcp_serverservice.yaml new file mode 100644 index 000000000..ecce7ffa4 --- /dev/null +++ b/manifests/mcp_serverservice.yaml @@ -0,0 +1,105 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcp-serverservice +spec: + selector: + matchLabels: + app: mcp-serverservice + replicas: 1 + template: + metadata: + labels: + app: mcp-serverservice + spec: + terminationGracePeriodSeconds: 5 + containers: + - name: server + image: labs.etsi.org:5050/tfs/controller/mcp_server:latest + imagePullPolicy: Always + ports: + - containerPort: 3002 + env: + - name: LOG_LEVEL + value: "INFO" + - name: TFS_MCP_PORT + value: "3002" + - name: TFS_MCP_MODE + value: "normal" + - name: TFS_NBI_URL + value: "http://nbiservice:8080" + - name: TFS_NBI_PREFIX + value: "/tfs-api" + readinessProbe: + httpGet: + path: /health + port: 3002 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /health + port: 3002 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 10 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: mcp-serverservice + labels: + app: mcp-serverservice +spec: + type: ClusterIP + selector: + app: mcp-serverservice + ports: + - name: http + protocol: TCP + port: 3002 + targetPort: 3002 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: tfs-ingress-mcp-server + annotations: + nginx.ingress.kubernetes.io/limit-rps: "50" # max requests per second per source IP + nginx.ingress.kubernetes.io/limit-connections: "50" # max concurrent connections per source IP + nginx.ingress.kubernetes.io/proxy-connect-timeout: "60" # max timeout for connecting to server + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" # max timeout between two successive read operations + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" # max timeout between two successive write operations +spec: + rules: + - http: + paths: + - path: /mcp + pathType: Prefix + backend: + service: + name: mcp-serverservice + port: + number: 3002 diff --git a/my_deploy.sh b/my_deploy.sh index 6a4918a82..5cca9a394 100644 --- a/my_deploy.sh +++ b/my_deploy.sh @@ -103,6 +103,10 @@ export TFS_COMPONENTS="context device pathcomp service nbi webui" # Uncomment to activate Pluggables Component #export TFS_COMPONENTS="${TFS_COMPONENTS} pluggables" +# Uncomment to activate MCP Server. +# Keep it last so it is deployed after optional components it can expose. +#export TFS_COMPONENTS="${TFS_COMPONENTS} mcp_server" + # Set the tag you want to use for your images. export TFS_IMAGE_TAG="dev" diff --git a/scripts/show_logs_mcp_server.sh b/scripts/show_logs_mcp_server.sh new file mode 100755 index 000000000..61f6fe341 --- /dev/null +++ b/scripts/show_logs_mcp_server.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +######################################################################################################################## +# Define your deployment settings here +######################################################################################################################## + +# If not already set, set the name of the Kubernetes namespace to deploy to. +export TFS_K8S_NAMESPACE=${TFS_K8S_NAMESPACE:-"tfs"} + +######################################################################################################################## +# Automated steps start here +######################################################################################################################## + +kubectl --namespace $TFS_K8S_NAMESPACE logs deployment/mcp-serverservice -c server diff --git a/src/common/Constants.py b/src/common/Constants.py index df7fc231f..811baf6be 100644 --- a/src/common/Constants.py +++ b/src/common/Constants.py @@ -46,6 +46,7 @@ class ServiceNameEnum(Enum): POLICY = 'policy' MONITORING = 'monitoring' DLT = 'dlt' + MCP_SERVER = 'mcp-server' NBI = 'nbi' SIMAP_CONNECTOR = 'simap-connector' CYBERSECURITY = 'cybersecurity' @@ -127,6 +128,7 @@ DEFAULT_SERVICE_GRPC_PORTS = { # Default HTTP/REST-API service ports DEFAULT_SERVICE_HTTP_PORTS = { + ServiceNameEnum.MCP_SERVER.value : 3002, ServiceNameEnum.NBI .value : 8080, ServiceNameEnum.WEBUI.value : 8004, ServiceNameEnum.ZTP_SERVER.value : 8005, @@ -134,6 +136,7 @@ DEFAULT_SERVICE_HTTP_PORTS = { # Default HTTP/REST-API service base URLs DEFAULT_SERVICE_HTTP_BASEURLS = { + ServiceNameEnum.MCP_SERVER.value : None, ServiceNameEnum.NBI .value : None, ServiceNameEnum.WEBUI.value : None, } diff --git a/src/mcp_server/.gitlab-ci.yml b/src/mcp_server/.gitlab-ci.yml new file mode 100644 index 000000000..a766f0d59 --- /dev/null +++ b/src/mcp_server/.gitlab-ci.yml @@ -0,0 +1,77 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +build mcp_server: + variables: + IMAGE_NAME: 'mcp_server' + IMAGE_TAG: "candidate-${CI_COMMIT_SHORT_SHA}" + stage: build + before_script: + - docker image prune --force + - bash scripts/dockerhub_login.sh + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - docker buildx build -t "$IMAGE_NAME:$IMAGE_TAG" -f ./src/$IMAGE_NAME/Dockerfile . + - docker tag "$IMAGE_NAME:$IMAGE_TAG" "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - docker push "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + after_script: + - docker image prune --force + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)' + - changes: + - src/common/**/*.py + - src/$IMAGE_NAME/**/*.{py,in,yml} + - src/$IMAGE_NAME/Dockerfile + - src/$IMAGE_NAME/tests/*.py + - manifests/${IMAGE_NAME}service.yaml + - .gitlab-ci.yml + +unit_test mcp_server: + variables: + IMAGE_NAME: 'mcp_server' + IMAGE_TAG: "candidate-${CI_COMMIT_SHORT_SHA}" + stage: unit_test + needs: + - build mcp_server + before_script: + - bash scripts/dockerhub_login.sh + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + - if docker network list | grep teraflowbridge; then echo "teraflowbridge is already created"; else docker network create -d bridge teraflowbridge; fi + - if docker container ls -a | grep $IMAGE_NAME; then docker rm -f $IMAGE_NAME; else echo "$IMAGE_NAME image is not in the system"; fi + script: + - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - docker run --name $IMAGE_NAME -d -p 3002:3002 -v "$PWD/src/$IMAGE_NAME/tests:/opt/results" --network=teraflowbridge --env LOG_LEVEL=INFO $CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG + - sleep 5 + - docker ps -a + - docker logs $IMAGE_NAME + - docker exec --user root -i $IMAGE_NAME sh -c "coverage run -m pytest --log-level=INFO --verbose ${IMAGE_NAME}/tests/test_unitary.py --junitxml=/opt/results/${IMAGE_NAME}_report.xml" + - docker exec --user root -i $IMAGE_NAME sh -c "coverage report --include='${IMAGE_NAME}/*' --show-missing" + coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/' + after_script: + - docker logs $IMAGE_NAME + - docker rm -f $IMAGE_NAME + - docker network rm teraflowbridge + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)' + - changes: + - src/common/**/*.py + - src/$IMAGE_NAME/**/*.{py,in,yml} + - src/$IMAGE_NAME/Dockerfile + - src/$IMAGE_NAME/tests/*.py + - manifests/${IMAGE_NAME}service.yaml + - .gitlab-ci.yml + artifacts: + when: always + reports: + junit: src/$IMAGE_NAME/tests/${IMAGE_NAME}_report.xml diff --git a/src/mcp_server/Config.py b/src/mcp_server/Config.py new file mode 100644 index 000000000..a36f5d3c5 --- /dev/null +++ b/src/mcp_server/Config.py @@ -0,0 +1,71 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from common.Constants import ServiceNameEnum +from common.Settings import get_service_host, get_service_port_http, get_setting + +DEFAULT_API_PREFIX = "/tfs-api" +DEFAULT_MODE = "normal" +DEFAULT_TRANSPORT = "sse" + +ENVVAR_TFS_MCP_MODE = "TFS_MCP_MODE" +ENVVAR_TFS_MCP_MOCK_INVENTORY_PATH = "TFS_MCP_MOCK_INVENTORY_PATH" +ENVVAR_TFS_MCP_TRANSPORT = "TFS_MCP_TRANSPORT" +ENVVAR_TFS_NBI_PREFIX = "TFS_NBI_PREFIX" +ENVVAR_TFS_NBI_URL = "TFS_NBI_URL" +ENVVAR_TFS_NBI_USER = "TFS_NBI_USER" +ENVVAR_TFS_NBI_PASS = "TFS_NBI_PASS" +ENVVAR_TFS_NBI_VERIFY_TLS = "TFS_NBI_VERIFY_TLS" + + +def get_mcp_server_port() -> int: + return get_service_port_http(ServiceNameEnum.MCP_SERVER) + + +def get_mcp_server_transport() -> str: + return get_setting(ENVVAR_TFS_MCP_TRANSPORT, default=DEFAULT_TRANSPORT) + + +def get_mcp_server_mode() -> str: + return get_setting(ENVVAR_TFS_MCP_MODE, default=DEFAULT_MODE) + + +def get_mock_inventory_path() -> str: + return get_setting(ENVVAR_TFS_MCP_MOCK_INVENTORY_PATH, default=None) + + +def get_tfs_nbi_url() -> str: + api_url = get_setting(ENVVAR_TFS_NBI_URL, default=None) + if api_url: + return api_url + nbi_host = get_service_host(ServiceNameEnum.NBI) + nbi_port = get_service_port_http(ServiceNameEnum.NBI) + return "http://{:s}:{:d}".format(nbi_host, nbi_port) + + +def get_tfs_nbi_prefix() -> str: + return get_setting(ENVVAR_TFS_NBI_PREFIX, default=DEFAULT_API_PREFIX) + + +def get_tfs_nbi_user() -> str: + return get_setting(ENVVAR_TFS_NBI_USER, default=None) + + +def get_tfs_nbi_pass() -> str: + return get_setting(ENVVAR_TFS_NBI_PASS, default=None) + + +def get_tfs_nbi_verify_tls() -> bool: + value = get_setting(ENVVAR_TFS_NBI_VERIFY_TLS, default="false") + return str(value).strip().lower() in {"1", "true", "yes", "on"} diff --git a/src/mcp_server/Dockerfile b/src/mcp_server/Dockerfile new file mode 100644 index 000000000..1e0fad553 --- /dev/null +++ b/src/mcp_server/Dockerfile @@ -0,0 +1,32 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=0 + +RUN python3 -m pip install --upgrade 'pip==25.2' +RUN python3 -m pip install --upgrade 'setuptools==79.0.0' 'wheel==0.45.1' +RUN python3 -m pip install --upgrade 'pip-tools==7.3.0' + +WORKDIR /var/teraflow/mcp_server +COPY src/mcp_server/requirements.in requirements.in +RUN pip-compile --quiet --output-file=requirements.txt requirements.in +RUN python3 -m pip install -r requirements.txt + +WORKDIR /var/teraflow +COPY src/common/. common/ +COPY src/mcp_server/. mcp_server/ + +ENTRYPOINT ["python", "-m", "mcp_server.service.App"] diff --git a/src/mcp_server/__init__.py b/src/mcp_server/__init__.py new file mode 100644 index 000000000..3c1e50ab2 --- /dev/null +++ b/src/mcp_server/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/src/mcp_server/requirements.in b/src/mcp_server/requirements.in new file mode 100644 index 000000000..0590dad09 --- /dev/null +++ b/src/mcp_server/requirements.in @@ -0,0 +1,23 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +mcp>=1.13,<2 +httpx>=0.27,<1 +python-dotenv>=1.0,<2 +starlette>=0.37,<1 +uvicorn>=0.30,<0.35 + +# Test support used by the component CI job. +coverage>=7,<8 +pytest>=8,<9 diff --git a/src/mcp_server/service/App.py b/src/mcp_server/service/App.py new file mode 100644 index 000000000..1076c6da8 --- /dev/null +++ b/src/mcp_server/service/App.py @@ -0,0 +1,101 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import asyncio + +from common.Constants import ServiceNameEnum +from common.Settings import ( + ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_HTTP, + get_env_var_name, wait_for_environment_variables +) + +from mcp_server.Config import ( + get_mcp_server_mode, get_mcp_server_port, get_mcp_server_transport, + get_mock_inventory_path, get_tfs_nbi_pass, get_tfs_nbi_prefix, + get_tfs_nbi_url, get_tfs_nbi_user, get_tfs_nbi_verify_tls +) + +from .Dependencies import LOGGER +from .McpServer import TfsMcpServer +from .tools.Definitions import TOOLS + + +def parse_args(): + parser = argparse.ArgumentParser(description="TFS MCP Server") + parser.add_argument("--transport", choices=["stdio", "sse"], default=get_mcp_server_transport(), + help="Transport type (default: sse)") + parser.add_argument("--port", type=int, default=get_mcp_server_port(), + help="Port for SSE transport (default: 3002)") + parser.add_argument("--api-url", + default=get_tfs_nbi_url(), + help="TFS NBI base URL (default: service-discovered NBI URL or $TFS_NBI_URL)") + parser.add_argument("--api-prefix", + default=get_tfs_nbi_prefix(), + help="TFS NBI API URL prefix (default: /tfs-api)") + parser.add_argument("--mode", choices=["normal", "mocked-tfs"], + default=get_mcp_server_mode(), + help="Operational mode: normal or mocked-tfs (default: normal)") + parser.add_argument("--mock-inventory-path", + default=get_mock_inventory_path(), + help="Inventory JSON path for mocked-tfs mode") + parser.add_argument("--username", default=get_tfs_nbi_user(), + help="Optional HTTP basic-auth username") + parser.add_argument("--password", default=get_tfs_nbi_pass(), + help="Optional HTTP basic-auth password") + parser.add_argument("--insecure", action="store_true", default=not get_tfs_nbi_verify_tls(), + help="Disable TLS verification") + return parser.parse_args() + + +def main(): + wait_for_environment_variables([ + get_env_var_name(ServiceNameEnum.NBI, ENVVAR_SUFIX_SERVICE_HOST), + get_env_var_name(ServiceNameEnum.NBI, ENVVAR_SUFIX_SERVICE_PORT_HTTP), + ]) + args = parse_args() + + server = TfsMcpServer( + transport=args.transport, + port=args.port, + api_url=args.api_url, + api_prefix=args.api_prefix, + username=args.username, + password=args.password, + verify_tls=not args.insecure, + mode=args.mode, + mock_inventory_path=args.mock_inventory_path, + ) + + if args.transport == "stdio": + asyncio.run(server._run_stdio_server()) + else: + LOGGER.info("Starting TFS MCP Server (SSE) on port %s", args.port) + LOGGER.info("SSE endpoint: http://localhost:%s/sse", args.port) + LOGGER.info("Mode: %s", server.mode) + if server.mode == "normal": + LOGGER.info("TFS NBI URL: %s%s", args.api_url, server.api_prefix) + elif server.mock_store is not None: + LOGGER.info("Mock inventory: %s", server.mock_store.inventory_path) + LOGGER.info("Available Tools: %d", len(TOOLS)) + for tool in TOOLS: + LOGGER.debug(" - %s", tool.name) + try: + asyncio.run(server._run_sse_server()) + except KeyboardInterrupt: + LOGGER.info("Shutting down...") + + +if __name__ == "__main__": + main() diff --git a/src/mcp_server/service/Dependencies.py b/src/mcp_server/service/Dependencies.py new file mode 100644 index 000000000..d9f7a6b93 --- /dev/null +++ b/src/mcp_server/service/Dependencies.py @@ -0,0 +1,34 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import logging +import os +from typing import Any + +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO").upper(), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +LOGGER = logging.getLogger(__name__) + + +def format_response(data: Any, indent: int = 2) -> str: + """Format response data as a JSON string.""" + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + return data + return json.dumps(data, indent=indent, default=str) diff --git a/src/mcp_server/service/McpServer.py b/src/mcp_server/service/McpServer.py new file mode 100644 index 000000000..473476f9c --- /dev/null +++ b/src/mcp_server/service/McpServer.py @@ -0,0 +1,396 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import copy +import threading +import time +import uuid +from typing import Any, Dict, List, Optional + +import httpx +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from mcp.server.stdio import stdio_server +from mcp.types import TextContent + +from .Dependencies import LOGGER, format_response +from .MockStore import MockTfsInventoryStore +from .tools.Definitions import TOOLS +from .tools.OpticalAllocation import verify_optical_allocation +from .tools.Registry import get_tool_handler + + +class TfsMcpServer: + """MCP Server bridging an MCP client to the TFS NBI REST API.""" + + def __init__( + self, + transport: str = "sse", + port: int = 3002, + api_url: str = "http://127.0.0.1:80", + api_prefix: str = "/tfs-api", + username: Optional[str] = None, + password: Optional[str] = None, + verify_tls: bool = False, + mode: str = "normal", + mock_inventory_path: Optional[str] = None, + ): + self.mode = mode + self.mock_store: Optional[MockTfsInventoryStore] = None + if self.mode not in {"normal", "mocked-tfs"}: + raise ValueError("Unsupported TFS MCP mode: {:s}".format(self.mode)) + if self.mode == "mocked-tfs": + self.mock_store = MockTfsInventoryStore(inventory_path=mock_inventory_path) + + self.transport = transport + self.port = port + self.api_url = api_url.rstrip('/') + self.api_prefix = '/' + api_prefix.strip('/') + self.auth = (username, password) if username and password else None + self.verify_tls = verify_tls + + self._server_thread: Optional[threading.Thread] = None + self._should_stop = threading.Event() + + self.mcp = Server("tfs-mcp-server") + self._register_handlers() + + LOGGER.info( + "TfsMcpServer initialized (mode=%s, transport=%s, port=%s, api_url=%s, prefix=%s)", + self.mode, transport, port, self.api_url, self.api_prefix, + ) + + async def api_request( + self, + method: str, + endpoint: str, + params: Optional[Dict] = None, + json_data: Optional[Any] = None, + timeout: float = 60.0, + use_prefix: bool = True, + ) -> Any: + """Issue an HTTP request to the TFS NBI.""" + prefix = self.api_prefix if use_prefix else '' + url = f"{self.api_url}{prefix}{endpoint}" + + async with httpx.AsyncClient(timeout=timeout, verify=self.verify_tls, auth=self.auth) as client: + try: + response = await client.request( + method=method, url=url, params=params, json=json_data, + ) + response.raise_for_status() + if not response.content: + return {"status": "ok", "http_status": response.status_code} + ctype = response.headers.get("content-type", "") + if "application/json" in ctype: + return response.json() + return {"status": "ok", "body": response.text} + except httpx.TimeoutException: + return {"error": f"Request timeout after {timeout}s", "status": "error", "url": url} + except httpx.HTTPStatusError as error: + return { + "error": f"HTTP {error.response.status_code}: {error.response.text}", + "status": "error", + "url": url, + } + except Exception as error: # pylint: disable=broad-except + return {"error": str(error), "status": "error", "url": url} + + @staticmethod + def normalize_optical_slice_payload(slice_payload: Any) -> Any: + """Ensure optical-slice payload has tapi-common:context.uuid. + + - If `tapi-common:context` exists but `uuid` is missing/empty, inject UUID. + - If it does not exist, create it with a generated UUID. + Supports payloads where the optical content is directly at root or under `data`. + """ + if not isinstance(slice_payload, dict): + return slice_payload + + normalized = copy.deepcopy(slice_payload) + container = normalized.get("data") if isinstance(normalized.get("data"), dict) else normalized + + context_obj = container.get("tapi-common:context") + if not isinstance(context_obj, dict): + container["tapi-common:context"] = {"uuid": str(uuid.uuid4())} + return normalized + + context_uuid = context_obj.get("uuid") + if not isinstance(context_uuid, str) or not context_uuid.strip(): + context_obj["uuid"] = str(uuid.uuid4()) + return normalized + + def _register_handlers(self): + @self.mcp.list_tools() + async def list_tools() -> List[Any]: + return TOOLS + + @self.mcp.call_tool() + async def call_tool(name: str, arguments: Dict[str, Any]) -> List[Any]: + try: + result = await self._handle_tool_call(name, arguments or {}) + except Exception as error: # pylint: disable=broad-except + LOGGER.exception("Tool call failed: %s", name) + result = format_response({"error": str(error), "tool": name}) + return [TextContent(type="text", text=result)] + + async def _handle_tool_call(self, name: str, args: Dict[str, Any]) -> str: + if self.mode == "mocked-tfs": + return self._handle_mock_tool_call(name, args) + return await get_tool_handler(name).handle(self, args) + + def _handle_mock_tool_call(self, name: str, args: Dict[str, Any]) -> str: + if self.mock_store is None: + return format_response({"status": "error", "error": "Mock store is not initialized"}) + + if name == "tfs_health_check": + return format_response({ + "status": "healthy", + "server": "tfs-mcp", + "mode": self.mode, + "inventory_path": str(self.mock_store.inventory_path), + }) + if name == "tfs_list_context_ids": + return format_response(self.mock_store.list_context_ids()) + if name == "tfs_list_contexts": + return format_response(self.mock_store.list_contexts()) + if name == "tfs_get_dummy_contexts": + return format_response(self.mock_store.get_dummy_contexts()) + if name == "tfs_get_context": + return format_response(self.mock_store.get_context(args["context_uuid"])) + + if name == "tfs_list_topology_ids": + return format_response(self.mock_store.list_topology_ids(args["context_uuid"])) + if name == "tfs_list_topologies": + return format_response(self.mock_store.list_topologies(args["context_uuid"])) + if name == "tfs_get_topology": + return format_response(self.mock_store.get_topology(args["context_uuid"], args["topology_uuid"])) + if name == "tfs_get_topology_details": + return format_response(self.mock_store.get_topology_details(args["context_uuid"], args["topology_uuid"])) + + if name == "tfs_list_device_ids": + return format_response(self.mock_store.list_device_ids()) + if name == "tfs_list_devices": + return format_response(self.mock_store.list_devices()) + if name == "tfs_get_device": + return format_response(self.mock_store.get_device(args["device_uuid"])) + + if name == "tfs_list_link_ids": + return format_response(self.mock_store.list_link_ids()) + if name == "tfs_list_links": + return format_response(self.mock_store.list_links()) + if name == "tfs_get_link": + return format_response(self.mock_store.get_link(args["link_uuid"])) + + if name == "tfs_list_optical_link_ids": + return format_response(self.mock_store.list_optical_link_ids()) + if name == "tfs_list_optical_links": + return format_response(self.mock_store.list_optical_links()) + if name == "tfs_get_optical_link": + return format_response(self.mock_store.get_optical_link(args["link_uuid"])) + if name == "tfs_compute_optical_connectivity_candidates": + return format_response(self.mock_store.compute_optical_connectivity_candidates( + args["context_uuid"], args["topology_uuid"], args["request"] + )) + if name == "tfs_list_optical_spectrum_reservations": + return format_response(self.mock_store.list_optical_spectrum_reservations(args["context_uuid"])) + if name == "tfs_get_optical_spectrum_reservation": + return format_response(self.mock_store.get_optical_spectrum_reservation( + args["context_uuid"], args["reservation_uuid"] + )) + if name == "tfs_create_optical_spectrum_reservation": + return format_response(self.mock_store.create_optical_spectrum_reservation( + args["context_uuid"], args["reservation"] + )) + if name == "tfs_update_optical_spectrum_reservation": + return format_response(self.mock_store.update_optical_spectrum_reservation( + args["context_uuid"], args["reservation_uuid"], args["reservation"] + )) + if name == "tfs_consume_optical_spectrum_reservation": + return format_response(self.mock_store.consume_optical_spectrum_reservation( + args["context_uuid"], args["reservation_uuid"], args["reservation"] + )) + if name == "tfs_release_optical_spectrum_reservation": + return format_response(self.mock_store.release_optical_spectrum_reservation( + args["context_uuid"], args["reservation_uuid"] + )) + if name == "tfs_delete_optical_spectrum_reservation": + return format_response(self.mock_store.delete_optical_spectrum_reservation( + args["context_uuid"], args["reservation_uuid"] + )) + if name == "tfs_get_optical_service_allocation": + return format_response({ + "status": "error", + "error": "Mocked TFS mode does not provide optical service allocation evidence", + }) + if name == "tfs_verify_optical_service_allocation": + return format_response(verify_optical_allocation( + { + "status": "error", + "error": "Mocked TFS mode does not provide optical service allocation evidence", + }, + args["expected_band"], + args["expected_n_start"], + args["expected_n_end"], + )) + if name == "tfs_list_all_connections": + return format_response(self.mock_store.list_all_connections(args["context_uuid"])) + + mutating_prefixes = ( + "tfs_set_", + "tfs_create_", + "tfs_update_", + "tfs_delete_", + "tfs_add_", + "tfs_configure_", + "tfs_optical_slice_", + "tfs_media_channel_", + "tfs_dscm_oc_", + ) + if name.startswith(mutating_prefixes): + return format_response({ + "status": "error", + "error": "Tool is not supported in mocked-tfs mode", + "tool": name, + "mode": self.mode, + }) + return format_response({"status": "error", "error": f"Unknown tool: {name}", "mode": self.mode}) + + async def list_all_connections(self, context_uuid: str) -> Dict[str, Any]: + services_payload = await self.api_request("GET", f"/context/{context_uuid}/services") + services = services_payload.get("services", []) if isinstance(services_payload, dict) else [] + connections: List[Dict[str, Any]] = [] + for service in services: + service_uuid = self._service_uuid(service) + if not service_uuid: + continue + payload = await self.api_request( + "GET", + f"/context/{context_uuid}/service/{service_uuid}/connections", + ) + service_connections = payload.get("connections", []) if isinstance(payload, dict) else [] + for connection in service_connections: + if isinstance(connection, dict): + connections.append(connection) + return { + "context_uuid": context_uuid, + "service_count": len(services), + "connections": connections, + } + + @staticmethod + def _service_uuid(service: Dict[str, Any]) -> str: + return str( + service.get("service_id", {}) + .get("service_uuid", {}) + .get("uuid", "") + or service.get("name", "") + ) + + async def _run_sse_server(self): + from starlette.applications import Starlette + from starlette.responses import JSONResponse, Response + from starlette.routing import Mount, Route + import uvicorn + + sse_transport = SseServerTransport("/messages/") + + async def handle_sse(request): + async with sse_transport.connect_sse( + request.scope, request.receive, request._send, + ) as streams: + await self.mcp.run( + streams[0], streams[1], self.mcp.create_initialization_options(), + ) + return Response() + + async def health(request): + payload = {"status": "healthy", "server": "tfs-mcp", "mode": self.mode} + if self.mock_store is not None: + payload.update({ + "inventory_path": str(self.mock_store.inventory_path), + "contexts": len(self.mock_store.contexts), + "topologies": len(self.mock_store.topologies), + "devices": len(self.mock_store.devices), + "links": len(self.mock_store.links), + "optical_links": len(self.mock_store.optical_links), + }) + return JSONResponse(payload) + + app = Starlette(routes=[ + Route("/sse", endpoint=handle_sse), + Route("/mcp/sse", endpoint=handle_sse), + Mount("/messages/", app=sse_transport.handle_post_message), + Mount("/mcp/messages/", app=sse_transport.handle_post_message), + Route("/health", endpoint=health), + Route("/healthz", endpoint=health), + Route("/mcp/health", endpoint=health), + Route("/mcp/healthz", endpoint=health), + ]) + + config = uvicorn.Config(app, host="0.0.0.0", port=self.port, log_level="info") + server = uvicorn.Server(config) + await server.serve() + + async def _run_stdio_server(self): + async with stdio_server() as (read_stream, write_stream): + await self.mcp.run( + read_stream, write_stream, self.mcp.create_initialization_options(), + ) + + def _run_server_thread(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + if self.transport == "stdio": + loop.run_until_complete(self._run_stdio_server()) + else: + loop.run_until_complete(self._run_sse_server()) + except Exception as error: # pylint: disable=broad-except + LOGGER.error("MCP server error: %s", error) + finally: + loop.close() + + def start(self) -> bool: + if self._server_thread and self._server_thread.is_alive(): + LOGGER.warning("MCP server is already running") + return False + + LOGGER.info("Starting TFS MCP server (%s transport)", self.transport) + self._should_stop.clear() + self._server_thread = threading.Thread( + target=self._run_server_thread, daemon=True, name="TfsMcpServerThread", + ) + self._server_thread.start() + + time.sleep(1) + + if self._server_thread.is_alive(): + LOGGER.info("TFS MCP server started successfully") + if self.transport == "sse": + LOGGER.info(" SSE endpoint: http://localhost:%s/sse", self.port) + return True + LOGGER.error("TFS MCP server failed to start") + return False + + def stop(self): + LOGGER.info("Stopping TFS MCP server...") + self._should_stop.set() + if self._server_thread and self._server_thread.is_alive(): + self._server_thread.join(timeout=5) + LOGGER.info("TFS MCP server stopped") + + def is_running(self) -> bool: + return self._server_thread is not None and self._server_thread.is_alive() diff --git a/src/mcp_server/service/MockStore.py b/src/mcp_server/service/MockStore.py new file mode 100644 index 000000000..d35c65575 --- /dev/null +++ b/src/mcp_server/service/MockStore.py @@ -0,0 +1,506 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Iterable, Optional + + +class MockTfsInventoryStore: + """In-memory TFS inventory used by mocked-TFS MCP mode.""" + + def __init__(self, inventory_path: Optional[str] = None): + self.inventory_path = Path(inventory_path) if inventory_path else self._default_inventory_path() + payload = json.loads(self.inventory_path.read_text(encoding="utf-8")) + + self.contexts = payload.get("contexts", []) + self.topologies = payload.get("topologies", []) + self.devices = payload.get("devices", []) + self.links = payload.get("links", []) + self.optical_links = payload.get("optical_links", []) + self.services = payload.get("services", []) + self.connections = payload.get("connections", []) + self.optical_spectrum_reservations = payload.get("optical_spectrum_reservations", []) + + self._context_index = self._index_by_uuid_and_name(self.contexts, self._context_uuid) + self._topology_index = self._index_by_uuid_and_name(self.topologies, self._topology_uuid) + self._device_index = self._index_by_uuid_and_name(self.devices, self._device_uuid) + self._link_index = self._index_by_uuid_and_name(self.links, self._link_uuid) + self._optical_link_index = self._index_by_uuid_and_name(self.optical_links, self._link_uuid) + self._reservation_index = self._index_reservations() + + @staticmethod + def _default_inventory_path() -> Path: + source_tree_path = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "dummy_contexts.json" + image_path = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "dummy_contexts.json" + for path in (source_tree_path, image_path): + if path.exists(): + return path + return source_tree_path + + @staticmethod + def _index_by_uuid_and_name(items: Iterable[Dict[str, Any]], uuid_getter) -> Dict[str, Dict[str, Any]]: + index: Dict[str, Dict[str, Any]] = {} + for item in items: + uuid_value = uuid_getter(item) + if uuid_value: + index[uuid_value] = item + name = item.get("name") + if isinstance(name, str) and name: + index[name] = item + return index + + @staticmethod + def _context_uuid(context: Dict[str, Any]) -> str: + return context.get("context_id", {}).get("context_uuid", {}).get("uuid", "") + + @staticmethod + def _topology_uuid(topology: Dict[str, Any]) -> str: + return topology.get("topology_id", {}).get("topology_uuid", {}).get("uuid", "") + + @staticmethod + def _topology_context_uuid(topology: Dict[str, Any]) -> str: + return ( + topology.get("topology_id", {}) + .get("context_id", {}) + .get("context_uuid", {}) + .get("uuid", "") + ) + + @staticmethod + def _device_uuid(device: Dict[str, Any]) -> str: + return device.get("device_id", {}).get("device_uuid", {}).get("uuid", "") + + @staticmethod + def _link_uuid(link: Dict[str, Any]) -> str: + return link.get("link_id", {}).get("link_uuid", {}).get("uuid", "") + + @staticmethod + def _reservation_uuid(reservation: Dict[str, Any]) -> str: + return ( + reservation.get("reservation_id", {}) + .get("reservation_uuid", {}) + .get("uuid", "") + ) + + @staticmethod + def _reservation_context_uuid(reservation: Dict[str, Any]) -> str: + return ( + reservation.get("reservation_id", {}) + .get("context_id", {}) + .get("context_uuid", {}) + .get("uuid", "") + ) + + @staticmethod + def _reservation_status(reservation: Dict[str, Any]) -> str: + status = reservation.get("status") + if status in (1, "1", "RESERVED"): + return "OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED" + if status in (2, "2", "CONSUMED"): + return "OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED" + if status in (3, "3", "RELEASED"): + return "OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED" + if status in (4, "4", "EXPIRED"): + return "OPTICALSPECTRUMRESERVATIONSTATUS_EXPIRED" + if isinstance(status, str) and status.startswith("OPTICALSPECTRUMRESERVATIONSTATUS_"): + return status + return "OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED" + + @staticmethod + def _reservation_link_uuids(reservation: Dict[str, Any]) -> set[str]: + link_uuids = set() + for link_id in reservation.get("optical_link_ids", []): + link_uuid = link_id.get("link_uuid", {}).get("uuid", "") if isinstance(link_id, dict) else "" + if link_uuid: + link_uuids.add(link_uuid) + return link_uuids + + @staticmethod + def _ranges_overlap(left_start: int, left_end: int, right_start: int, right_end: int) -> bool: + return left_start <= right_end and right_start <= left_end + + def _index_reservations(self) -> Dict[str, Dict[str, Any]]: + index: Dict[str, Dict[str, Any]] = {} + for reservation in self.optical_spectrum_reservations: + reservation_uuid = self._reservation_uuid(reservation) + if reservation_uuid: + index[reservation_uuid] = reservation + return index + + def _active_reservations(self, context_uuid: str) -> list[Dict[str, Any]]: + resolved_context_uuid = self._resolve_context_uuid(context_uuid) + active_statuses = { + "OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED", + "OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED", + } + return [ + reservation for reservation in self.optical_spectrum_reservations + if self._reservation_context_uuid(reservation) == resolved_context_uuid + and self._reservation_status(reservation) in active_statuses + ] + + def _find_reservation(self, context_uuid: str, reservation_uuid: str) -> Optional[Dict[str, Any]]: + resolved_context_uuid = self._resolve_context_uuid(context_uuid) + reservation = self._reservation_index.get(reservation_uuid) + if reservation is None: + return None + if self._reservation_context_uuid(reservation) != resolved_context_uuid: + return None + return reservation + + def _reservation_conflict(self, context_uuid: str, candidate: Dict[str, Any]) -> Optional[Dict[str, Any]]: + candidate_links = self._reservation_link_uuids(candidate) + candidate_band = candidate.get("band", "c_slots") + candidate_start = int(candidate.get("n_start", 0)) + candidate_end = int(candidate.get("n_end", 0)) + candidate_uuid = self._reservation_uuid(candidate) + for reservation in self._active_reservations(context_uuid): + if self._reservation_uuid(reservation) == candidate_uuid: + continue + if reservation.get("band", "c_slots") != candidate_band: + continue + if not candidate_links.intersection(self._reservation_link_uuids(reservation)): + continue + if self._ranges_overlap( + candidate_start, candidate_end, + int(reservation.get("n_start", 0)), int(reservation.get("n_end", 0)), + ): + return reservation + return None + + def _set_reservation_status(self, reservation: Dict[str, Any], status: str) -> Dict[str, Any]: + reservation["status"] = status + return reservation + + def _resolve_context_uuid(self, context_uuid_or_name: str) -> Optional[str]: + context = self._context_index.get(context_uuid_or_name) + return None if context is None else self._context_uuid(context) + + def _resolve_topology_uuid(self, topology_uuid_or_name: str) -> Optional[str]: + topology = self._topology_index.get(topology_uuid_or_name) + return None if topology is None else self._topology_uuid(topology) + + def _topologies_for_context(self, context_uuid_or_name: str) -> list[Dict[str, Any]]: + context_uuid = self._resolve_context_uuid(context_uuid_or_name) + if context_uuid is None: + return [] + return [ + topology for topology in self.topologies + if self._topology_context_uuid(topology) == context_uuid + ] + + def list_context_ids(self) -> Dict[str, Any]: + return { + "context_ids": [ + {"context_uuid": {"uuid": self._context_uuid(context)}} + for context in self.contexts + ] + } + + def list_contexts(self) -> Dict[str, Any]: + contexts = [] + for context in self.contexts: + context_uuid = self._context_uuid(context) + topology_ids = [ + topology["topology_id"] + for topology in self._topologies_for_context(context_uuid) + ] + contexts.append({ + "context_id": context["context_id"], + "name": context.get("name", context_uuid), + "topology_ids": topology_ids, + "service_ids": context.get("service_ids", []), + "slice_ids": context.get("slice_ids", []), + }) + return {"contexts": contexts} + + def get_context(self, context_uuid: str) -> Dict[str, Any]: + context = self._context_index.get(context_uuid) + if context is None: + return {"status": "error", "error": f"Unknown context_uuid: {context_uuid}"} + resolved_uuid = self._context_uuid(context) + for item in self.list_contexts()["contexts"]: + if self._context_uuid(item) == resolved_uuid: + return item + return {"status": "error", "error": f"Unknown context_uuid: {context_uuid}"} + + def list_topology_ids(self, context_uuid: str) -> Dict[str, Any]: + return { + "topology_ids": [ + topology["topology_id"] + for topology in self._topologies_for_context(context_uuid) + ] + } + + def list_topologies(self, context_uuid: str) -> Dict[str, Any]: + if self._resolve_context_uuid(context_uuid) is None: + return {"status": "error", "error": f"Unknown context_uuid: {context_uuid}"} + return { + "topologies": [ + self._compact_topology(topology) + for topology in self._topologies_for_context(context_uuid) + ] + } + + def _compact_topology(self, topology: Dict[str, Any]) -> Dict[str, Any]: + return { + "topology_id": topology["topology_id"], + "name": topology.get("name", self._topology_uuid(topology)), + "device_ids": topology.get("device_ids", [device["device_id"] for device in self.devices]), + "link_ids": topology.get("link_ids", [link["link_id"] for link in self.links]), + "optical_link_ids": topology.get( + "optical_link_ids", + [link["link_id"] for link in self.optical_links], + ), + } + + def get_topology(self, context_uuid: str, topology_uuid: str) -> Dict[str, Any]: + resolved_context_uuid = self._resolve_context_uuid(context_uuid) + resolved_topology_uuid = self._resolve_topology_uuid(topology_uuid) + for topology in self.topologies: + if self._topology_context_uuid(topology) != resolved_context_uuid: + continue + if self._topology_uuid(topology) == resolved_topology_uuid: + return self._compact_topology(topology) + return {"status": "error", "error": f"Unknown topology {context_uuid}/{topology_uuid}"} + + def get_topology_details(self, context_uuid: str, topology_uuid: str) -> Dict[str, Any]: + topology = self.get_topology(context_uuid, topology_uuid) + if topology.get("status") == "error": + return topology + return { + "topology_id": topology["topology_id"], + "name": topology.get("name", topology_uuid), + "devices": self.devices, + "links": self.links, + "optical_links": self.optical_links, + } + + def list_device_ids(self) -> Dict[str, Any]: + return {"device_ids": [device["device_id"] for device in self.devices]} + + def list_devices(self) -> Dict[str, Any]: + return {"devices": self.devices} + + def get_device(self, device_uuid: str) -> Dict[str, Any]: + device = self._device_index.get(device_uuid) + if device is None: + return {"status": "error", "error": f"Unknown device_uuid: {device_uuid}"} + return device + + def list_link_ids(self) -> Dict[str, Any]: + return {"link_ids": [link["link_id"] for link in self.links]} + + def list_links(self) -> Dict[str, Any]: + return {"links": self.links} + + def get_link(self, link_uuid: str) -> Dict[str, Any]: + link = self._link_index.get(link_uuid) + if link is None: + return {"status": "error", "error": f"Unknown link_uuid: {link_uuid}"} + return link + + def list_optical_link_ids(self) -> Dict[str, Any]: + return {"link_ids": [link["link_id"] for link in self.optical_links]} + + def list_optical_links(self) -> Dict[str, Any]: + return {"optical_links": self.optical_links} + + def get_optical_link(self, link_uuid: str) -> Dict[str, Any]: + link = self._optical_link_index.get(link_uuid) + if link is None: + return {"status": "error", "error": f"Unknown optical link_uuid: {link_uuid}"} + return link + + def compute_optical_connectivity_candidates( + self, context_uuid: str, topology_uuid: str, request: Dict[str, Any] + ) -> Dict[str, Any]: + if self._resolve_context_uuid(context_uuid) is None: + return {"candidates": [], "rejected_reasons": [{"code": "UNKNOWN_CONTEXT", "message": context_uuid}]} + if self._resolve_topology_uuid(topology_uuid) is None: + return {"candidates": [], "rejected_reasons": [{"code": "UNKNOWN_TOPOLOGY", "message": topology_uuid}]} + required_slots = int(request.get("required_slots") or 4) + if request.get("channel_width_ghz") is not None: + required_slots = max(1, int((float(request["channel_width_ghz"]) + 12.5 - 0.001) // 12.5)) + optical_link_ids = [ + link["link_id"]["link_uuid"]["uuid"] + for link in self.optical_links[: max(1, min(3, len(self.optical_links)))] + if "link_id" in link and "link_uuid" in link["link_id"] + ] + if not optical_link_ids: + return { + "required_slots": required_slots, + "candidates": [], + "rejected_reasons": [{"code": "NO_OPTICAL_TOPOLOGY", "message": "No optical links are available"}], + "request_summary": request, + } + selected_start = self._first_available_slot_range( + context_uuid, optical_link_ids, request.get("preferred_band", "c_slots"), required_slots + ) + if selected_start is None: + return { + "required_slots": required_slots, + "effective_channel_width_ghz": required_slots * 12.5, + "candidates": [], + "rejected_reasons": [{"code": "NO_COMMON_SPECTRUM", "message": "No common mock slots available"}], + "request_summary": request, + } + selected_end = selected_start + required_slots - 1 + return { + "required_slots": required_slots, + "effective_channel_width_ghz": required_slots * 12.5, + "candidates": [{ + "candidate_uuid": "mock-candidate-1", + "validation_status": "VALID", + "band": request.get("preferred_band", "c_slots"), + "n_start": selected_start, + "n_end": selected_end, + "required_slots": required_slots, + "optical_link_ids": optical_link_ids, + "path_hops": [ + {"sequence": index, "optical_link_id": link_uuid} + for index, link_uuid in enumerate(optical_link_ids) + ], + "path_metric": len(optical_link_ids), + "available_slots_summary": { + "common_available_slots": 320 - len(self._active_reservations(context_uuid)), + "selected_range": "{:d}-{:d}".format(selected_start, selected_end), + }, + }], + "rejected_reasons": [], + "request_summary": request, + } + + def _first_available_slot_range( + self, context_uuid: str, optical_link_ids: list[str], band: str, required_slots: int + ) -> Optional[int]: + max_slot = 320 + for n_start in range(1, max_slot - required_slots + 2): + n_end = n_start + required_slots - 1 + candidate = { + "reservation_id": { + "context_id": {"context_uuid": {"uuid": self._resolve_context_uuid(context_uuid) or context_uuid}}, + "reservation_uuid": {"uuid": "__candidate__"}, + }, + "optical_link_ids": [{"link_uuid": {"uuid": link_uuid}} for link_uuid in optical_link_ids], + "band": band, + "n_start": n_start, + "n_end": n_end, + } + if self._reservation_conflict(context_uuid, candidate) is None: + return n_start + return None + + def list_optical_spectrum_reservations(self, context_uuid: str) -> Dict[str, Any]: + if self._resolve_context_uuid(context_uuid) is None: + return {"status": "error", "error": f"Unknown context_uuid: {context_uuid}"} + return { + "reservations": [ + reservation for reservation in self.optical_spectrum_reservations + if self._reservation_context_uuid(reservation) == self._resolve_context_uuid(context_uuid) + ] + } + + def get_optical_spectrum_reservation(self, context_uuid: str, reservation_uuid: str) -> Dict[str, Any]: + reservation = self._find_reservation(context_uuid, reservation_uuid) + if reservation is None: + return {"status": "error", "error": f"Unknown reservation_uuid: {reservation_uuid}"} + return reservation + + def create_optical_spectrum_reservation(self, context_uuid: str, reservation: Dict[str, Any]) -> Any: + if self._resolve_context_uuid(context_uuid) is None: + return {"status": "error", "error": f"Unknown context_uuid: {context_uuid}"} + reservation_uuid = self._reservation_uuid(reservation) + if not reservation_uuid: + return {"status": "error", "error": "Missing reservation_id.reservation_uuid.uuid"} + conflict = self._reservation_conflict(context_uuid, reservation) + if conflict is not None: + return { + "status": "error", + "error": "ALREADY_EXISTS", + "message": "overlapping spectrum reservation", + "conflict_reservation_uuid": self._reservation_uuid(conflict), + } + stored = dict(reservation) + self._set_reservation_status(stored, "OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED") + existing = self._find_reservation(context_uuid, reservation_uuid) + if existing is None: + self.optical_spectrum_reservations.append(stored) + else: + existing.update(stored) + stored = existing + self._reservation_index[reservation_uuid] = stored + return [{"reservation_uuid": {"uuid": reservation_uuid}}] + + def update_optical_spectrum_reservation( + self, context_uuid: str, reservation_uuid: str, reservation: Dict[str, Any] + ) -> Dict[str, Any]: + existing = self._find_reservation(context_uuid, reservation_uuid) + if existing is None: + return {"status": "error", "error": f"Unknown reservation_uuid: {reservation_uuid}"} + existing.update(reservation) + existing.setdefault("reservation_id", reservation.get("reservation_id", {})) + return {"reservation_uuid": {"uuid": reservation_uuid}} + + def consume_optical_spectrum_reservation( + self, context_uuid: str, reservation_uuid: str, reservation: Dict[str, Any] + ) -> Dict[str, Any]: + existing = self._find_reservation(context_uuid, reservation_uuid) + if existing is None: + return {"status": "error", "error": f"Unknown reservation_uuid: {reservation_uuid}"} + if reservation.get("service_id"): + existing["service_id"] = reservation["service_id"] + if reservation.get("connection_id"): + existing["connection_id"] = reservation["connection_id"] + self._set_reservation_status(existing, "OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED") + return {"reservation_uuid": {"uuid": reservation_uuid}} + + def release_optical_spectrum_reservation(self, context_uuid: str, reservation_uuid: str) -> Dict[str, Any]: + existing = self._find_reservation(context_uuid, reservation_uuid) + if existing is None: + return {"status": "error", "error": f"Unknown reservation_uuid: {reservation_uuid}"} + self._set_reservation_status(existing, "OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED") + return {} + + def delete_optical_spectrum_reservation(self, context_uuid: str, reservation_uuid: str) -> Dict[str, Any]: + existing = self._find_reservation(context_uuid, reservation_uuid) + if existing is None: + return {"status": "error", "error": f"Unknown reservation_uuid: {reservation_uuid}"} + self.optical_spectrum_reservations.remove(existing) + self._reservation_index.pop(reservation_uuid, None) + return {} + + def list_all_connections(self, context_uuid: str) -> Dict[str, Any]: + if self._resolve_context_uuid(context_uuid) is None: + return {"status": "error", "error": f"Unknown context_uuid: {context_uuid}"} + return { + "context_uuid": context_uuid, + "service_count": len(self.services), + "connections": self.connections, + } + + def get_dummy_contexts(self) -> Dict[str, Any]: + return { + "dummy_mode": True, + "contexts": self.list_contexts()["contexts"], + "topologies": self.list_topologies("admin").get("topologies", []), + "devices": self.devices, + "links": self.links, + "optical_links": self.optical_links, + "services": self.services, + "connections": self.connections, + } diff --git a/src/mcp_server/service/__init__.py b/src/mcp_server/service/__init__.py new file mode 100644 index 000000000..83ff88c4c --- /dev/null +++ b/src/mcp_server/service/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .McpServer import TfsMcpServer + +__all__ = ["TfsMcpServer"] diff --git a/src/mcp_server/service/tools/Base.py b/src/mcp_server/service/tools/Base.py new file mode 100644 index 000000000..e359dcabf --- /dev/null +++ b/src/mcp_server/service/tools/Base.py @@ -0,0 +1,48 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Callable, Dict, Optional + +from mcp_server.service.Dependencies import format_response + + +class ToolHandler: + async def handle(self, server, args: Dict[str, Any]) -> str: + raise NotImplementedError() + + +class NbiRestToolHandler(ToolHandler): + def __init__( + self, + method: str, + endpoint: str, + json_factory: Optional[Callable[[Dict[str, Any]], Any]] = None, + use_prefix: bool = True, + timeout: float = 60.0, + ): + self.method = method + self.endpoint = endpoint + self.json_factory = json_factory + self.use_prefix = use_prefix + self.timeout = timeout + + async def handle(self, server, args: Dict[str, Any]) -> str: + json_data = None if self.json_factory is None else self.json_factory(args) + return format_response(await server.api_request( + self.method, + self.endpoint.format(**args), + json_data=json_data, + timeout=self.timeout, + use_prefix=self.use_prefix, + )) diff --git a/src/mcp_server/service/tools/Custom.py b/src/mcp_server/service/tools/Custom.py new file mode 100644 index 000000000..264d51244 --- /dev/null +++ b/src/mcp_server/service/tools/Custom.py @@ -0,0 +1,92 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Dict + +from mcp_server.service.Dependencies import format_response +from mcp_server.service.tools.Base import ToolHandler +from mcp_server.service.tools.OpticalAllocation import verify_optical_allocation + + +class ListOpticalLinkIdsTool(ToolHandler): + async def handle(self, server, args: Dict[str, Any]) -> str: + optical_links = await server.api_request("GET", "/optical_links") + if not isinstance(optical_links, dict) or "optical_links" not in optical_links: + return format_response(optical_links) + return format_response({ + "link_ids": [ + optical_link["link_id"] + for optical_link in optical_links.get("optical_links", []) + if isinstance(optical_link, dict) and "link_id" in optical_link + ] + }) + + +class VerifyOpticalServiceAllocationTool(ToolHandler): + async def handle(self, server, args: Dict[str, Any]) -> str: + allocation = await server.api_request( + "GET", + "/context/{:s}/service/{:s}/optical_allocation".format( + args["context_uuid"], args["service_uuid"] + ), + ) + return format_response(verify_optical_allocation( + allocation, + args["expected_band"], + args["expected_n_start"], + args["expected_n_end"], + )) + + +class ListAllConnectionsTool(ToolHandler): + async def handle(self, server, args: Dict[str, Any]) -> str: + return format_response(await server.list_all_connections(args["context_uuid"])) + + +class OpticalSliceCreateTool(ToolHandler): + async def handle(self, server, args: Dict[str, Any]) -> str: + normalized_slice = server.normalize_optical_slice_payload(args["slice"]) + return format_response(await server.api_request( + "POST", + "/restconf/optical-slice/v1/service/{:s}".format(args["slice_id"]), + json_data=normalized_slice, + use_prefix=False, + )) + + +class DscmOpenConfigTool(ToolHandler): + def __init__(self, method: str, body_key: str = None): + self.method = method + self.body_key = body_key + + async def handle(self, server, args: Dict[str, Any]) -> str: + endpoint = "/restconf/data/device={:s}/{:s}".format( + args["device_uuid"], + args["rc_path"].lstrip("/"), + ) + json_data = None if self.body_key is None else args[self.body_key] + return format_response(await server.api_request( + self.method, + endpoint, + json_data=json_data, + use_prefix=False, + )) + + +class UnknownTool(ToolHandler): + def __init__(self, name: str): + self.name = name + + async def handle(self, server, args: Dict[str, Any]) -> str: + return format_response({"error": "Unknown tool: {:s}".format(self.name)}) diff --git a/src/mcp_server/service/tools/Definitions.py b/src/mcp_server/service/tools/Definitions.py new file mode 100644 index 000000000..a13c88a1e --- /dev/null +++ b/src/mcp_server/service/tools/Definitions.py @@ -0,0 +1,1008 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, List + +from mcp.types import Tool + + +# Reusable JSON-Schema fragments +_CTX = {"context_uuid": {"type": "string", "description": "UUID of the TFS Context (e.g., 'admin')"}} +_TOPO = {"topology_uuid": {"type": "string", "description": "UUID of the Topology"}} +_SVC = {"service_uuid": {"type": "string", "description": "UUID of the Service"}} +_SLC = {"slice_uuid": {"type": "string", "description": "UUID of the Slice"}} +_DEV = {"device_uuid": {"type": "string", "description": "UUID of the Device"}} +# ============================================================================= +# JSON-Schema helpers derived from context.proto +# The TFS NBI REST layer serialises proto messages to/from JSON using the +# standard protobuf JSON mapping (snake_case field names, enum as integer). +# These schemas let the LLM know exactly what to send and what to expect. +# ============================================================================= + +# ---- primitive wrapper ---- +_UUID_PROP = {"type": "object", "properties": {"uuid": {"type": "string"}}, "required": ["uuid"]} + +# ---- ID messages ---- +_CTX_ID_S = {"type": "object", "description": "ContextId", + "properties": {"context_uuid": _UUID_PROP}, "required": ["context_uuid"]} +_TOPO_ID_S = {"type": "object", "description": "TopologyId", + "properties": {"context_id": _CTX_ID_S, "topology_uuid": _UUID_PROP}, + "required": ["context_id", "topology_uuid"]} +_DEV_ID_S = {"type": "object", "description": "DeviceId", + "properties": {"device_uuid": _UUID_PROP}, "required": ["device_uuid"]} +_SVC_ID_S = {"type": "object", "description": "ServiceId", + "properties": {"context_id": _CTX_ID_S, "service_uuid": _UUID_PROP}, + "required": ["context_id", "service_uuid"]} +_SLC_ID_S = {"type": "object", "description": "SliceId", + "properties": {"context_id": _CTX_ID_S, "slice_uuid": _UUID_PROP}, + "required": ["context_id", "slice_uuid"]} +_LNK_ID_S = {"type": "object", "description": "LinkId", + "properties": {"link_uuid": _UUID_PROP}, "required": ["link_uuid"]} +_CON_ID_S = {"type": "object", "description": "ConnectionId", + "properties": {"connection_uuid": _UUID_PROP}, "required": ["connection_uuid"]} +_POL_ID_S = {"type": "object", "description": "PolicyRuleId", + "properties": {"policyrule_uuid": _UUID_PROP}, "required": ["policyrule_uuid"]} + +# ---- EndPointId ---- +_ENDPOINT_ID_S = { + "type": "object", + "description": "EndPointId: identifies a device port/interface within a topology", + "properties": { + "topology_id": _TOPO_ID_S, + "device_id": _DEV_ID_S, + "endpoint_uuid": _UUID_PROP, + }, +} + +# ---- ConfigRule ---- +_CONFIG_RULE_S = { + "type": "object", + "description": "ConfigRule - action: 1=SET, 2=DELETE", + "properties": { + "action": { + "oneOf": [{"type": "integer"}, {"type": "string"}], + "description": "ConfigActionEnum: 1=CONFIGACTION_SET, 2=CONFIGACTION_DELETE", + }, + "custom": { + "type": "object", + "description": "Custom key/value pair config rule", + "properties": { + "resource_key": {"type": "string", + "description": "Config path, e.g. '/endpoints/endpoint[uuid]//name'"}, + "resource_value": {"type": "string", + "description": "JSON-serialised value string"}, + }, + }, + }, + "required": ["action"], +} + +# ---- Constraint ---- +_CONSTRAINT_S = { + "type": "object", + "description": "Constraint - action: 1=SET, 2=DELETE. Include exactly one constraint field.", + "properties": { + "action": { + "oneOf": [{"type": "integer"}, {"type": "string"}], + "description": "ConstraintActionEnum: 1=SET, 2=DELETE", + }, + "custom": {"type": "object", + "properties": {"constraint_type": {"type": "string"}, + "constraint_value": {"type": "string"}}}, + "sla_capacity": {"type": "object", + "properties": {"capacity_gbps": {"type": "number"}}}, + "sla_latency": {"type": "object", + "properties": {"e2e_latency_ms": {"type": "number"}}}, + "sla_availability": {"type": "object", + "properties": {"num_disjoint_paths": {"type": "integer"}, + "all_active": {"type": "boolean"}, + "availability": {"type": "number", + "description": "0.0-100.0 %"}}}, + "endpoint_location": {"type": "object", + "properties": {"endpoint_id": _ENDPOINT_ID_S, + "location": {"type": "object", + "properties": { + "region": {"type": "string"}, + "gps_position": {"type": "object", + "properties": { + "latitude": {"type": "number"}, + "longitude": {"type": "number"}}}}}}}, + }, + "required": ["action"], +} + +# ---- URL path-parameter helpers ---- +_P_CTX = {"context_uuid": {"type": "string", "description": "Context UUID (default 'admin')"}} +_P_TOPO = {"topology_uuid": {"type": "string", "description": "Topology UUID"}} +_P_SVC = {"service_uuid": {"type": "string", "description": "Service UUID"}} +_P_SLC = {"slice_uuid": {"type": "string", "description": "Slice UUID"}} +_P_DEV = {"device_uuid": {"type": "string", "description": "Device UUID"}} +_P_LNK = {"link_uuid": {"type": "string", "description": "Link UUID"}} +_P_CON = {"connection_uuid": {"type": "string", "description": "Connection UUID"}} +_P_POL = {"policyrule_uuid": {"type": "string", "description": "PolicyRule UUID"}} +_P_OSR = {"reservation_uuid": {"type": "string", "description": "Optical spectrum reservation UUID"}} + +_OPTICAL_SPECTRUM_RESERVATION_BODY = { + "type": "object", + "description": ( + "OpticalSpectrumReservation. Required fields for creation are " + "reservation_id, topology_id, optical_link_ids, band, n_start, n_end, " + "required_slots, owner_id, correlation_id, and usually status=1 " + "(RESERVED). Consume requests may include service_id and/or " + "connection_id." + ), + "properties": { + "reservation_id": { + "type": "object", + "properties": { + "context_id": _CTX_ID_S, + "reservation_uuid": _UUID_PROP, + }, + "required": ["context_id", "reservation_uuid"], + }, + "topology_id": _TOPO_ID_S, + "optical_link_ids": { + "type": "array", + "items": _LNK_ID_S, + "description": "Optical links whose common slot range is reserved.", + }, + "band": {"type": "string", "description": "Spectrum band, e.g. c_slots."}, + "n_start": {"type": "integer", "description": "First flex-grid slot index."}, + "n_end": {"type": "integer", "description": "Last flex-grid slot index, inclusive."}, + "required_slots": {"type": "integer", "description": "Number of contiguous slots."}, + "owner_id": {"type": "string", "description": "Agent or workflow owning the reservation."}, + "correlation_id": {"type": "string", "description": "Workflow/request correlation identifier."}, + "status": { + "oneOf": [{"type": "integer"}, {"type": "string"}], + "description": "OpticalSpectrumReservationStatusEnum.", + }, + "service_id": _SVC_ID_S, + "connection_id": _CON_ID_S, + }, +} + +# ---- Full proto message body schemas for POST/PUT operations ---- + +_CONTEXT_BODY = { + "type": "object", + "description": ( + "Context proto message (JSON). " + "Returns: {context_id:{context_uuid:{uuid}}, name, " + "topology_ids:[{context_id,topology_uuid}], " + "service_ids:[{context_id,service_uuid}], " + "slice_ids:[{context_id,slice_uuid}]}" + ), + "properties": { + "context_id": _CTX_ID_S, + "name": {"type": "string"}, + "topology_ids": {"type": "array", "items": _TOPO_ID_S}, + "service_ids": {"type": "array", "items": _SVC_ID_S}, + "slice_ids": {"type": "array", "items": _SLC_ID_S}, + }, + "required": ["context_id"], +} + +_TOPOLOGY_BODY = { + "type": "object", + "description": ( + "Topology proto message (JSON). " + "Returns: {topology_id:{context_id,topology_uuid}, name, " + "device_ids:[{device_uuid}], link_ids:[{link_uuid}]}" + ), + "properties": { + "topology_id": _TOPO_ID_S, + "name": {"type": "string"}, + "device_ids": {"type": "array", "items": _DEV_ID_S}, + "link_ids": {"type": "array", "items": _LNK_ID_S}, + }, + "required": ["topology_id"], +} + +_DEVICE_BODY = { + "type": "object", + "description": ( + "Device proto message (JSON). " + "device_operational_status: 0=UNDEFINED, 1=DISABLED, 2=ENABLED. " + "device_drivers (DeviceDriverEnum ints): 0=UNDEFINED/emulated, 1=OPENCONFIG, " + "2=TRANSPORT_API, 3=P4, 4=IETF_NETWORK_TOPOLOGY, 5=ONF_TR_532, 6=XR, " + "8=GNMI_OPENCONFIG, 9=OPTICAL_TFS, 11=OC, 12=QKD, 13=IETF_L3VPN, " + "14=IETF_SLICE, 18=RYU, 20=OPENROADM, 21=RESTCONF_OPENCONFIG. " + "Returns: {device_id, name, device_type, device_config:{config_rules}, " + "device_operational_status, device_drivers, device_endpoints:[EndPoint]}" + ), + "properties": { + "device_id": _DEV_ID_S, + "name": {"type": "string"}, + "device_type": {"type": "string", + "description": "Free-form type, e.g. 'emu-packet-router', 'emu-optical-roadm'"}, + "device_config": { + "type": "object", + "properties": {"config_rules": {"type": "array", "items": _CONFIG_RULE_S}}, + }, + "device_operational_status": {"type": "integer", + "description": "0=UNDEFINED, 1=DISABLED, 2=ENABLED"}, + "device_drivers": {"type": "array", "items": {"type": "integer"}, + "description": "List of DeviceDriverEnum integer values"}, + "device_endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "endpoint_id": _ENDPOINT_ID_S, + "name": {"type": "string"}, + "endpoint_type": {"type": "string"}, + }, + }, + }, + }, + "required": ["device_id"], +} + +_SERVICE_BODY = { + "type": "object", + "description": ( + "Service proto message (JSON). " + "service_type (ServiceTypeEnum): 0=UNKNOWN, 1=L3NM, 2=L2NM, " + "3=TAPI_CONNECTIVITY_SERVICE, 4=TE, 5=E2E, 6=OPTICAL_CONNECTIVITY, " + "7=QKD, 8=L1NM, 9=INT, 10=ACL, 11=IP_LINK, 12=TAPI_LSP, 13=IPOWDM, 14=UPF. " + "service_status.service_status (ServiceStatusEnum): 0=UNDEFINED, 1=PLANNED, " + "2=ACTIVE, 3=UPDATING, 4=PENDING_REMOVAL, 5=SLA_VIOLATED. " + "Returns: {service_id, name, service_type, service_endpoint_ids:[EndPointId], " + "service_constraints:[Constraint], service_status, service_config:{config_rules}, timestamp}" + ), + "properties": { + "service_id": _SVC_ID_S, + "name": {"type": "string"}, + "service_type": { + "oneOf": [{"type": "integer"}, {"type": "string"}], + "description": "ServiceTypeEnum value", + }, + "service_endpoint_ids": {"type": "array", "items": _ENDPOINT_ID_S}, + "service_constraints": {"type": "array", "items": _CONSTRAINT_S}, + "service_status": { + "type": "object", + "properties": { + "service_status": { + "oneOf": [{"type": "integer"}, {"type": "string"}], + "description": "ServiceStatusEnum value", + } + }, + }, + "service_config": { + "type": "object", + "properties": {"config_rules": {"type": "array", "items": _CONFIG_RULE_S}}, + }, + }, + "required": ["service_id"], +} + +_SLICE_BODY = { + "type": "object", + "description": ( + "Slice proto message (JSON). " + "slice_status.slice_status (SliceStatusEnum): 0=UNDEFINED, 1=PLANNED, " + "2=INIT, 3=ACTIVE, 4=DEINIT, 5=SLA_VIOLATED. " + "Returns: {slice_id, name, slice_endpoint_ids:[EndPointId], " + "slice_constraints:[Constraint], slice_service_ids:[ServiceId], " + "slice_subslice_ids:[SliceId], slice_status, slice_config:{config_rules}, " + "slice_owner:{owner_uuid,owner_string}, timestamp}" + ), + "properties": { + "slice_id": _SLC_ID_S, + "name": {"type": "string"}, + "slice_endpoint_ids": {"type": "array", "items": _ENDPOINT_ID_S}, + "slice_constraints": {"type": "array", "items": _CONSTRAINT_S}, + "slice_service_ids": {"type": "array", "items": _SVC_ID_S}, + "slice_subslice_ids": {"type": "array", "items": _SLC_ID_S}, + "slice_status": { + "type": "object", + "properties": {"slice_status": {"type": "integer", + "description": "SliceStatusEnum value"}}, + }, + "slice_config": { + "type": "object", + "properties": {"config_rules": {"type": "array", "items": _CONFIG_RULE_S}}, + }, + "slice_owner": { + "type": "object", + "properties": {"owner_uuid": _UUID_PROP, "owner_string": {"type": "string"}}, + }, + }, + "required": ["slice_id"], +} + +_LINK_BODY = { + "type": "object", + "description": ( + "Link proto message (JSON). " + "link_type (LinkTypeEnum): 0=UNKNOWN, 1=COPPER, 2=FIBER, 3=RADIO, " + "4=VIRTUAL, 5=MANAGEMENT, 6=REMOTE. " + "Returns: {link_id:{link_uuid}, name, link_type, " + "link_endpoint_ids:[EndPointId] (exactly 2), " + "attributes:{is_bidirectional, total_capacity_gbps, used_capacity_gbps}}" + ), + "properties": { + "link_id": _LNK_ID_S, + "name": {"type": "string"}, + "link_type": {"type": "integer", + "description": "LinkTypeEnum: 0=UNKNOWN,1=COPPER,2=FIBER,3=RADIO,4=VIRTUAL,5=MANAGEMENT,6=REMOTE"}, + "link_endpoint_ids": {"type": "array", "items": _ENDPOINT_ID_S, + "description": "Exactly 2 EndPointIds defining the endpoints of the link"}, + "attributes": { + "type": "object", + "properties": { + "is_bidirectional": {"type": "boolean"}, + "total_capacity_gbps": {"type": "number"}, + "used_capacity_gbps": {"type": "number"}, + }, + }, + }, + "required": ["link_id"], +} + +# Connection is read-only from the NBI - no body schema needed. +# Returns: {connection_id:{connection_uuid}, service_id:{context_id,service_uuid}, +# path_hops_endpoint_ids:[EndPointId], sub_service_ids:[ServiceId], +# settings:{l0:{lsp_symbolic_name}, l2:{src_mac,dst_mac,ether_type,vlan_id,...}, +# l3:{src_ip,dst_ip,dscp,protocol,ttl}, +# l4:{src_port,dst_port,tcp_flags,ttl}}} + + +# ============================================================================= +# Tool Definitions +# ============================================================================= + +if Tool is None: + TOOLS: List[Any] = [] +else: + TOOLS: List[Tool] = [ + Tool(name="tfs_list_context_ids", + description=( + "List all TFS Context IDs (GET /tfs-api/context_ids). " + "Returns: {context_ids:[{context_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_list_contexts", + description=( + "List all TFS Contexts with full details (GET /tfs-api/contexts). " + "Returns: {contexts:[{context_id, name, topology_ids, service_ids, slice_ids}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_set_contexts", + description=( + "Create / update one or more TFS Contexts (POST /tfs-api/contexts). " + "Returns: {context_ids:[ContextId]}" + ), + inputSchema={ + "type": "object", + "properties": { + "contexts": {"type": "array", "items": _CONTEXT_BODY, + "description": "List of Context proto messages"} + }, + "required": ["contexts"], + }), + Tool(name="tfs_get_dummy_contexts", + description=( + "Get a single aggregated snapshot with all contexts, topologies, devices, links, " + "slices, services, and connections (GET /tfs-api/dummy_contexts). " + "Returns: {contexts:[Context], topologies:[Topology], devices:[Device], " + "links:[Link], slices:[Slice], services:[Service], connections:[Connection]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_get_context", + description=( + "Get a specific TFS Context by UUID (GET /tfs-api/context/). " + "Returns: Context {context_id, name, topology_ids, service_ids, slice_ids}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_set_context", + description=( + "Create / update a TFS Context (PUT /tfs-api/context/). " + "Returns: ContextId {context_uuid:{uuid}}" + ), + inputSchema={"type": "object", + "properties": {**_P_CTX, "context": _CONTEXT_BODY}, + "required": ["context_uuid", "context"]}), + Tool(name="tfs_delete_context", + description="Delete a TFS Context (DELETE /tfs-api/context/). Returns: {}", + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_list_topology_ids", + description=( + "List Topology IDs within a Context (GET /tfs-api/context//topology_ids). " + "Returns: {topology_ids:[{context_id, topology_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_list_topologies", + description=( + "List Topologies within a Context (GET /tfs-api/context//topologies). " + "Returns: {topologies:[{topology_id, name, device_ids, link_ids}]}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_set_topologies", + description=( + "Create / update Topologies within a Context " + "(POST /tfs-api/context//topologies). " + "Returns: {topology_ids:[TopologyId]}" + ), + inputSchema={"type": "object", + "properties": {**_P_CTX, + "topologies": {"type": "array", "items": _TOPOLOGY_BODY}}, + "required": ["context_uuid", "topologies"]}), + Tool(name="tfs_get_topology", + description=( + "Get a Topology (GET /tfs-api/context//topology/). " + "Returns: Topology {topology_id, name, device_ids:[DeviceId], link_ids:[LinkId]}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_TOPO}, + "required": ["context_uuid", "topology_uuid"]}), + Tool(name="tfs_set_topology", + description=( + "Create / update a specific Topology " + "(PUT /tfs-api/context//topology/). " + "Returns: TopologyId {context_id, topology_uuid:{uuid}}" + ), + inputSchema={"type": "object", + "properties": {**_P_CTX, **_P_TOPO, "topology": _TOPOLOGY_BODY}, + "required": ["context_uuid", "topology_uuid", "topology"]}), + Tool(name="tfs_delete_topology", + description=( + "Delete a Topology " + "(DELETE /tfs-api/context//topology/). Returns: {}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_TOPO}, + "required": ["context_uuid", "topology_uuid"]}), + Tool(name="tfs_get_topology_details", + description=( + "Get Topology with fully populated devices and links " + "(GET /tfs-api/context//topology_details/). " + "Returns: TopologyDetails {topology_id, name, " + "devices:[Device (full)], links:[Link (full)]}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_TOPO}, + "required": ["context_uuid", "topology_uuid"]}), + Tool(name="tfs_list_service_ids", + description=( + "List Service IDs in a Context (GET /tfs-api/context//service_ids). " + "Returns: {service_ids:[{context_id, service_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_list_services", + description=( + "List Services in a Context (GET /tfs-api/context//services). " + "Returns: {services:[Service {service_id, name, service_type, " + "service_endpoint_ids, service_constraints, service_status, service_config}]}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_create_services", + description=( + "Create one or more Services in a Context " + "(POST /tfs-api/context//services). " + "Returns: {service_ids:[ServiceId]}" + ), + inputSchema={"type": "object", + "properties": {**_P_CTX, + "services": {"type": "array", "items": _SERVICE_BODY}}, + "required": ["context_uuid", "services"]}), + Tool(name="tfs_get_service", + description=( + "Get a Service (GET /tfs-api/context//service/). " + "Returns: Service {service_id, name, service_type, service_endpoint_ids, " + "service_constraints, service_status, service_config:{config_rules}, timestamp}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_SVC}, + "required": ["context_uuid", "service_uuid"]}), + Tool(name="tfs_update_service", + description=( + "Update a Service (PUT /tfs-api/context//service/). " + "Returns: ServiceId {context_id, service_uuid:{uuid}}" + ), + inputSchema={"type": "object", + "properties": {**_P_CTX, **_P_SVC, "service": _SERVICE_BODY}, + "required": ["context_uuid", "service_uuid", "service"]}), + Tool(name="tfs_delete_service", + description=( + "Delete a Service (DELETE /tfs-api/context//service/). " + "Returns: {}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_SVC}, + "required": ["context_uuid", "service_uuid"]}), + Tool(name="tfs_get_optical_service_allocation", + description=( + "Get controller-observed effective optical allocation for a Service " + "(GET /tfs-api/context//service//optical_allocation). " + "Returns service state, flow ID, band, selected slots, slot range, frequency, " + "bandwidth, path, links, and connections when the controller has allocation evidence." + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_SVC}, + "required": ["context_uuid", "service_uuid"]}), + Tool(name="tfs_verify_optical_service_allocation", + description=( + "Verify a Service optical allocation against an expected slot interval. " + "This calls the controller-observed optical allocation endpoint and returns " + "match=true only when the service is ACTIVE and the effective selected slots " + "fall within the expected band/range." + ), + inputSchema={ + "type": "object", + "properties": { + **_P_CTX, + **_P_SVC, + "expected_band": {"type": "string", "description": "Expected band, e.g. C_BAND or c_slots."}, + "expected_n_start": {"type": "integer", "description": "Expected first slot."}, + "expected_n_end": {"type": "integer", "description": "Expected last slot."}, + }, + "required": ["context_uuid", "service_uuid", "expected_band", "expected_n_start", "expected_n_end"], + }), + Tool(name="tfs_list_slice_ids", + description=( + "List Slice IDs in a Context (GET /tfs-api/context//slice_ids). " + "Returns: {slice_ids:[{context_id, slice_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_list_slices", + description=( + "List Slices in a Context (GET /tfs-api/context//slices). " + "Returns: {slices:[Slice {slice_id, name, slice_endpoint_ids, " + "slice_constraints, slice_service_ids, slice_status, slice_config}]}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_create_slices", + description=( + "Create one or more Slices in a Context " + "(POST /tfs-api/context//slices). " + "Returns: {slice_ids:[SliceId]}" + ), + inputSchema={"type": "object", + "properties": {**_P_CTX, + "slices": {"type": "array", "items": _SLICE_BODY}}, + "required": ["context_uuid", "slices"]}), + Tool(name="tfs_get_slice", + description=( + "Get a Slice (GET /tfs-api/context//slice/). " + "Returns: Slice {slice_id, name, slice_endpoint_ids:[EndPointId], " + "slice_constraints, slice_service_ids, slice_subslice_ids, " + "slice_status:{slice_status}, slice_config:{config_rules}, slice_owner, timestamp}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_SLC}, + "required": ["context_uuid", "slice_uuid"]}), + Tool(name="tfs_update_slice", + description=( + "Update a Slice (PUT /tfs-api/context//slice/). " + "Returns: SliceId {context_id, slice_uuid:{uuid}}" + ), + inputSchema={"type": "object", + "properties": {**_P_CTX, **_P_SLC, "slice": _SLICE_BODY}, + "required": ["context_uuid", "slice_uuid", "slice"]}), + Tool(name="tfs_delete_slice", + description=( + "Delete a Slice (DELETE /tfs-api/context//slice/). " + "Returns: {}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_SLC}, + "required": ["context_uuid", "slice_uuid"]}), + Tool(name="tfs_list_device_ids", + description=( + "List all Device IDs (GET /tfs-api/device_ids). " + "Returns: {device_ids:[{device_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_list_devices", + description=( + "List all Devices with full details (GET /tfs-api/devices). " + "Returns: {devices:[Device {device_id, name, device_type, device_config, " + "device_operational_status, device_drivers, device_endpoints:[EndPoint]}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_add_devices", + description=( + "Add one or more Devices (POST /tfs-api/devices). " + "Returns: {device_ids:[DeviceId]}" + ), + inputSchema={"type": "object", + "properties": {"devices": {"type": "array", "items": _DEVICE_BODY}}, + "required": ["devices"]}), + Tool(name="tfs_get_device", + description=( + "Get a Device by UUID (GET /tfs-api/device/). " + "Returns: Device {device_id, name, device_type, " + "device_config:{config_rules:[ConfigRule]}, device_operational_status, " + "device_drivers:[int], device_endpoints:[EndPoint]}" + ), + inputSchema={"type": "object", "properties": _P_DEV, "required": ["device_uuid"]}), + Tool(name="tfs_configure_device", + description=( + "Configure / update a Device (PUT /tfs-api/device/). " + "Typically used to push config_rules onto an existing device. " + "Returns: DeviceId {device_uuid:{uuid}}" + ), + inputSchema={"type": "object", + "properties": {**_P_DEV, "device": _DEVICE_BODY}, + "required": ["device_uuid", "device"]}), + Tool(name="tfs_delete_device", + description="Delete a Device (DELETE /tfs-api/device/). Returns: {}", + inputSchema={"type": "object", "properties": _P_DEV, "required": ["device_uuid"]}), + Tool(name="tfs_list_link_ids", + description=( + "List all Link IDs (GET /tfs-api/link_ids). " + "Returns: {link_ids:[{link_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_list_links", + description=( + "List all Links (GET /tfs-api/links). " + "Returns: {links:[Link {link_id, name, link_type, " + "link_endpoint_ids:[EndPointId], attributes:{is_bidirectional, " + "total_capacity_gbps, used_capacity_gbps}}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_set_links", + description=( + "Create / update one or more Links (POST /tfs-api/links). " + "Virtual links (link_type=4) are routed via the VNT manager automatically. " + "Returns: {link_ids:[LinkId]}" + ), + inputSchema={"type": "object", + "properties": {"links": {"type": "array", "items": _LINK_BODY}}, + "required": ["links"]}), + Tool(name="tfs_get_link", + description=( + "Get a Link by UUID (GET /tfs-api/link/). " + "Returns: Link {link_id, name, link_type, link_endpoint_ids:[EndPointId], " + "attributes:{is_bidirectional, total_capacity_gbps, used_capacity_gbps}}" + ), + inputSchema={"type": "object", "properties": _P_LNK, "required": ["link_uuid"]}), + Tool(name="tfs_set_link", + description=( + "Create / update a specific Link (PUT /tfs-api/link/). " + "Returns: LinkId {link_uuid:{uuid}}" + ), + inputSchema={"type": "object", + "properties": {**_P_LNK, "link": _LINK_BODY}, + "required": ["link_uuid", "link"]}), + Tool(name="tfs_delete_link", + description="Delete a Link (DELETE /tfs-api/link/). Returns: {}", + inputSchema={"type": "object", "properties": _P_LNK, "required": ["link_uuid"]}), + Tool(name="tfs_list_optical_link_ids", + description=( + "List all Optical Link IDs (GET /tfs-api/optical_link_ids if available, " + "or derived from /tfs-api/optical_links in mocked mode). " + "Returns: {link_ids:[{link_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_list_optical_links", + description=( + "List all Optical Links (GET /tfs-api/optical_links). " + "Returns: {optical_links:[OpticalLink {link_id, name, link_endpoint_ids, optical_details}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_get_optical_link", + description=( + "Get an Optical Link by UUID (GET /tfs-api/optical_link/). " + "Returns: OpticalLink {link_id, name, link_endpoint_ids, optical_details}" + ), + inputSchema={"type": "object", "properties": _P_LNK, "required": ["link_uuid"]}), + Tool(name="tfs_compute_optical_connectivity_candidates", + description=( + "Compute optical path and spectrum candidates through the TFS optical controller " + "facade (POST /tfs-api/context//topology//" + "optical_connectivity_candidates). This is read-only planning: it does not create " + "services or reserve spectrum. Returns {required_slots, effective_channel_width_ghz, " + "candidates:[{candidate_uuid, band, n_start, n_end, required_slots, optical_link_ids, " + "path_hops, path_metric}], rejected_reasons, request_summary}." + ), + inputSchema={ + "type": "object", + "properties": { + **_CTX, + **_TOPO, + "request": { + "type": "object", + "properties": { + "src_endpoint_id": _ENDPOINT_ID_S, + "dst_endpoint_id": _ENDPOINT_ID_S, + "capacity_gbps": {"type": "number"}, + "modulation_format": {"type": "string"}, + "channel_width_ghz": {"type": "number"}, + "explicit_channel_width_ghz": {"type": "number"}, + "preferred_band": {"type": "string"}, + "preferred_n_start": {"type": "integer"}, + "preferred_n_end": {"type": "integer"}, + "max_candidates": {"type": "integer"}, + "include_reserved_slots": {"type": "boolean"}, + }, + }, + }, + "required": ["context_uuid", "topology_uuid", "request"], + }), + Tool(name="tfs_list_optical_spectrum_reservations", + description=( + "List controller-authoritative optical spectrum reservations in a context " + "(GET /tfs-api/context//optical_spectrum_reservations). " + "Use this for source-of-truth evidence before and after negotiation." + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_get_optical_spectrum_reservation", + description=( + "Get one controller-authoritative optical spectrum reservation " + "(GET /tfs-api/context//optical_spectrum_reservation/)." + ), + inputSchema={ + "type": "object", + "properties": {**_P_CTX, **_P_OSR}, + "required": ["context_uuid", "reservation_uuid"], + }), + Tool(name="tfs_create_optical_spectrum_reservation", + description=( + "Create a tentative controller-side optical spectrum reservation " + "(POST /tfs-api/context//optical_spectrum_reservations). " + "TFS rejects overlapping active reservations or occupied slots with HTTP 409. " + "This is the MCP operation to block negotiated slots before service creation." + ), + inputSchema={ + "type": "object", + "properties": { + **_P_CTX, + "reservation": _OPTICAL_SPECTRUM_RESERVATION_BODY, + }, + "required": ["context_uuid", "reservation"], + }), + Tool(name="tfs_update_optical_spectrum_reservation", + description=( + "Update an existing optical spectrum reservation " + "(PUT /tfs-api/context//optical_spectrum_reservation/). " + "Use sparingly; create/consume/release are preferred lifecycle operations." + ), + inputSchema={ + "type": "object", + "properties": { + **_P_CTX, + **_P_OSR, + "reservation": _OPTICAL_SPECTRUM_RESERVATION_BODY, + }, + "required": ["context_uuid", "reservation_uuid", "reservation"], + }), + Tool(name="tfs_consume_optical_spectrum_reservation", + description=( + "Consume/commit a reserved optical spectrum range after the corresponding " + "TFS service becomes ACTIVE " + "(POST /tfs-api/context//optical_spectrum_reservation/" + "/consume). Include service_id and/or connection_id evidence " + "in the reservation body when available." + ), + inputSchema={ + "type": "object", + "properties": { + **_P_CTX, + **_P_OSR, + "reservation": _OPTICAL_SPECTRUM_RESERVATION_BODY, + }, + "required": ["context_uuid", "reservation_uuid", "reservation"], + }), + Tool(name="tfs_release_optical_spectrum_reservation", + description=( + "Release/rollback an optical spectrum reservation without deleting its audit record " + "(POST /tfs-api/context//optical_spectrum_reservation/" + "/release). Use this on negotiation or provisioning failure." + ), + inputSchema={ + "type": "object", + "properties": {**_P_CTX, **_P_OSR}, + "required": ["context_uuid", "reservation_uuid"], + }), + Tool(name="tfs_delete_optical_spectrum_reservation", + description=( + "Delete an optical spectrum reservation record " + "(DELETE /tfs-api/context//optical_spectrum_reservation/" + "). Use only for cleanup when audit retention is not needed." + ), + inputSchema={ + "type": "object", + "properties": {**_P_CTX, **_P_OSR}, + "required": ["context_uuid", "reservation_uuid"], + }), + Tool(name="tfs_list_connection_ids", + description=( + "List Connection IDs of a Service " + "(GET /tfs-api/context//service//connection_ids). " + "Returns: {connection_ids:[{connection_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_SVC}, + "required": ["context_uuid", "service_uuid"]}), + Tool(name="tfs_list_connections", + description=( + "List Connections of a Service " + "(GET /tfs-api/context//service//connections). " + "Returns: {connections:[Connection {connection_id, service_id, " + "path_hops_endpoint_ids:[EndPointId], sub_service_ids, " + "settings:{l0:{lsp_symbolic_name}, l2:{src_mac,dst_mac,ether_type,vlan_id}, " + "l3:{src_ip,dst_ip,dscp,protocol,ttl}, l4:{src_port,dst_port,tcp_flags}}}]}" + ), + inputSchema={"type": "object", "properties": {**_P_CTX, **_P_SVC}, + "required": ["context_uuid", "service_uuid"]}), + Tool(name="tfs_list_all_connections", + description=( + "List all Connections in a Context by discovering services first and then " + "retrieving each service's connections. Use this read-only tool for questions " + "such as 'list existing connections' when no service UUID is provided. " + "Returns: {context_uuid, service_count, connections:[Connection]}" + ), + inputSchema={"type": "object", "properties": _P_CTX, "required": ["context_uuid"]}), + Tool(name="tfs_get_connection", + description=( + "Get a Connection by UUID (GET /tfs-api/connection/). " + "Returns: Connection {connection_id, service_id:{context_id,service_uuid}, " + "path_hops_endpoint_ids:[EndPointId], sub_service_ids:[ServiceId], " + "settings:{l0,l2,l3,l4}}" + ), + inputSchema={"type": "object", "properties": _P_CON, "required": ["connection_uuid"]}), + Tool(name="tfs_list_policyrule_ids", + description=( + "List all Policy Rule IDs (GET /tfs-api/policyrule_ids). " + "Returns: {policyrule_ids:[{policyrule_uuid:{uuid}}]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_list_policyrules", + description=( + "List all Policy Rules (GET /tfs-api/policyrules). " + "Returns: {policyrules:[PolicyRule]}" + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_get_policyrule", + description=( + "Get a Policy Rule by UUID (GET /tfs-api/policyrule/). " + "Returns: PolicyRule {policyrule_id, name, ...}" + ), + inputSchema={"type": "object", "properties": _P_POL, "required": ["policyrule_uuid"]}), + Tool(name="tfs_health_check", + description=( + "Probe the TFS NBI service for liveness (GET /healthz). " + "Returns: HTTP 200 with {} when healthy." + ), + inputSchema={"type": "object", "properties": {}, "required": []}), + Tool(name="tfs_optical_slice_create", + description=( + "Create / request an end-to-end OPTICAL SLICE " + "(POST /restconf/optical-slice/v1/service/). " + "This is the RESTCONF optical-slice API and is distinct from " + "TFS OPTICAL_CONNECTIVITY service provisioning through /tfs-api. " + "Use this only for optical slice intents, not for optical " + "connectivity service intents. " + "The controller resolves the slice into one or more spectrum " + "allocations (media channels) along the optical path. " + "Input MUST be a full optical-slice payload (not just src/dst names). " + "If `tapi-common:context.uuid` is missing, server auto-generates it." + ), + inputSchema={ + "type": "object", + "properties": { + "slice_id": {"type": "string", "description": "Identifier for the optical slice"}, + "slice": {"type": "object", + "description": "Optical-slice request body (TFS optical-slice schema). Include `tapi-common:context` with service-interface-points/constraints as required by controller; uuid may be omitted and will be generated."} + }, + "required": ["slice_id", "slice"] + }), + Tool(name="tfs_optical_slice_delete", + description=( + "Delete / release an OPTICAL SLICE and its associated spectrum " + "allocations (DELETE /restconf/optical-slice/v1/service/)." + ), + inputSchema={ + "type": "object", + "properties": { + "slice_id": {"type": "string", "description": "Identifier of the optical slice to delete"} + }, + "required": ["slice_id"] + }), + Tool(name="tfs_media_channel_allocate", + description=( + "Allocate an OPTICAL SPECTRUM SLOT (media channel) on a fiber/ROADM path " + "(POST /restconf/media-channel/v1/service/). " + "A media channel = a contiguous spectrum allocation defined by " + "center frequency and slot width (e.g., flexgrid n / m). " + "This is the primary endpoint used to commit a spectrum negotiation result." + ), + inputSchema={ + "type": "object", + "properties": { + "allocation_id": {"type": "string", + "description": "Identifier for the media-channel allocation"}, + "media_channel": { + "type": "object", + "description": ( + "Media-channel request body. Typical fields include source/destination " + "endpoints, center frequency (THz / 0.0125 THz units), slot width, " + "lower/upper frequency bounds, optical-channel name, and target power." + ) + } + }, + "required": ["allocation_id", "media_channel"] + }), + Tool(name="tfs_media_channel_release", + description=( + "Release / tear down an optical spectrum allocation " + "(DELETE /restconf/media-channel/v1/service/)." + ), + inputSchema={ + "type": "object", + "properties": { + "allocation_id": {"type": "string", + "description": "Identifier of the media channel to release"} + }, + "required": ["allocation_id"] + }), + Tool(name="tfs_dscm_oc_get", + description=( + "RESTCONF GET on a per-device OpenConfig YANG path used for optical / " + "DSCM (Dynamic Spectrum & Capacity Management) configuration " + "(GET /restconf/data/device=/). " + "Use this to read optical-channel state - e.g. frequency, " + "operational-mode, target-output-power - from a transponder or ROADM." + ), + inputSchema={ + "type": "object", + "properties": { + "device_uuid": {"type": "string", "description": "Target device UUID"}, + "rc_path": { + "type": "string", + "description": ( + "OpenConfig RESTCONF path under the device, e.g. " + "'openconfig-platform:components/component=och-1/optical-channel/config'." + ) + } + }, + "required": ["device_uuid", "rc_path"] + }), + Tool(name="tfs_dscm_oc_post", + description=( + "RESTCONF POST to push an OpenConfig optical-channel / spectrum " + "configuration onto a device " + "(POST /restconf/data/device=/). " + "Use this to configure center frequency, operational-mode, target output " + "power and other DSCM-OC parameters on a transponder or ROADM." + ), + inputSchema={ + "type": "object", + "properties": { + "device_uuid": {"type": "string", "description": "Target device UUID"}, + "rc_path": {"type": "string", + "description": "OpenConfig RESTCONF path under the device"}, + "config": { + "type": "object", + "description": ( + "YANG-data+json payload to push (OpenConfig optical-channel config, " + "e.g. {'openconfig-terminal-device:optical-channel': {'config': " + "{'frequency': 193100000, 'target-output-power': -1.0, " + "'operational-mode': 1}}})." + ) + } + }, + "required": ["device_uuid", "rc_path", "config"] + }), + Tool(name="tfs_dscm_oc_delete", + description=( + "RESTCONF DELETE on a per-device OpenConfig path " + "(DELETE /restconf/data/device=/). " + "Use this to remove an optical-channel / spectrum configuration " + "from a transponder or ROADM." + ), + inputSchema={ + "type": "object", + "properties": { + "device_uuid": {"type": "string", "description": "Target device UUID"}, + "rc_path": {"type": "string", + "description": "OpenConfig RESTCONF path under the device"} + }, + "required": ["device_uuid", "rc_path"] + }), + ] diff --git a/src/mcp_server/service/tools/OpticalAllocation.py b/src/mcp_server/service/tools/OpticalAllocation.py new file mode 100644 index 000000000..4ed32035f --- /dev/null +++ b/src/mcp_server/service/tools/OpticalAllocation.py @@ -0,0 +1,78 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Dict + + +def _normalize_band_name(value: Any) -> str: + text = str(value or "").strip().lower().replace("-", "_") + if text in {"c", "c_band", "c_slots"}: + return "c_slots" + if text in {"l", "l_band", "l_slots"}: + return "l_slots" + if text in {"s", "s_band", "s_slots"}: + return "s_slots" + return text + + +def _service_status_value(allocation: Dict[str, Any]) -> str: + service_status = allocation.get("service_status") + if isinstance(service_status, dict): + return str(service_status.get("service_status", "")) + return str(service_status or "") + + +def verify_optical_allocation( + allocation: Any, expected_band: str, expected_n_start: int, expected_n_end: int +) -> Dict[str, Any]: + if not isinstance(allocation, dict): + return {"ok": False, "match": False, "reason": "allocation response is not an object", "allocation": allocation} + if allocation.get("status") == "error" or allocation.get("error"): + return {"ok": False, "match": False, "reason": "allocation retrieval failed", "allocation": allocation} + + expected_start = int(expected_n_start) + expected_end = int(expected_n_end) + expected_band_normalized = _normalize_band_name(expected_band) + effective_band = _normalize_band_name(allocation.get("band_type")) + slots = allocation.get("slots") or [] + slot_values = sorted([int(slot) for slot in slots]) + service_status = _service_status_value(allocation) + + active = service_status == "SERVICESTATUS_ACTIVE" + band_matches = effective_band == expected_band_normalized + slots_available = len(slot_values) > 0 + slots_within_expected = ( + slots_available and slot_values[0] >= expected_start and slot_values[-1] <= expected_end + ) + match = active and band_matches and slots_within_expected + reasons = [] + if not active: + reasons.append("service is not ACTIVE") + if not band_matches: + reasons.append("effective band differs from expected band") + if not slots_available: + reasons.append("allocation has no effective slots") + elif not slots_within_expected: + reasons.append("effective slots are outside the expected interval") + return { + "ok": match, + "match": match, + "service_status": service_status, + "expected_band": expected_band_normalized, + "effective_band": effective_band, + "expected_slot_range": {"n_start": expected_start, "n_end": expected_end}, + "effective_slots": slot_values, + "reason": "; ".join(reasons) if reasons else "allocation matches expected range", + "allocation": allocation, + } diff --git a/src/mcp_server/service/tools/Registry.py b/src/mcp_server/service/tools/Registry.py new file mode 100644 index 000000000..92c6c85a5 --- /dev/null +++ b/src/mcp_server/service/tools/Registry.py @@ -0,0 +1,252 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Dict + +from mcp_server.service.tools.Base import NbiRestToolHandler, ToolHandler +from mcp_server.service.tools.Custom import ( + DscmOpenConfigTool, ListAllConnectionsTool, ListOpticalLinkIdsTool, + OpticalSliceCreateTool, UnknownTool, VerifyOpticalServiceAllocationTool +) + + +def _body(key: str): + return lambda args: args[key] + + +def _list_body(body_name: str, arg_name: str): + return lambda args: {body_name: args[arg_name]} + + +TOOL_REGISTRY: Dict[str, ToolHandler] = { + "tfs_list_context_ids": NbiRestToolHandler( + "GET", "/context_ids", + ), + "tfs_list_contexts": NbiRestToolHandler( + "GET", "/contexts", + ), + "tfs_set_contexts": NbiRestToolHandler( + "POST", "/contexts", + _list_body("contexts", "contexts"), + ), + "tfs_get_dummy_contexts": NbiRestToolHandler( + "GET", "/dummy_contexts", + ), + "tfs_get_context": NbiRestToolHandler( + "GET", "/context/{context_uuid}", + ), + "tfs_set_context": NbiRestToolHandler( + "PUT", "/context/{context_uuid}", + _body("context"), + ), + "tfs_delete_context": NbiRestToolHandler( + "DELETE", "/context/{context_uuid}", + ), + + "tfs_list_topology_ids": NbiRestToolHandler( + "GET", "/context/{context_uuid}/topology_ids", + ), + "tfs_list_topologies": NbiRestToolHandler( + "GET", "/context/{context_uuid}/topologies", + ), + "tfs_set_topologies": NbiRestToolHandler( + "POST", "/context/{context_uuid}/topologies", + _list_body("topologies", "topologies"), + ), + "tfs_get_topology": NbiRestToolHandler( + "GET", "/context/{context_uuid}/topology/{topology_uuid}", + ), + "tfs_set_topology": NbiRestToolHandler( + "PUT", "/context/{context_uuid}/topology/{topology_uuid}", + _body("topology"), + ), + "tfs_delete_topology": NbiRestToolHandler( + "DELETE", "/context/{context_uuid}/topology/{topology_uuid}", + ), + "tfs_get_topology_details": NbiRestToolHandler( + "GET", "/context/{context_uuid}/topology_details/{topology_uuid}", + ), + + "tfs_list_service_ids": NbiRestToolHandler( + "GET", "/context/{context_uuid}/service_ids", + ), + "tfs_list_services": NbiRestToolHandler( + "GET", "/context/{context_uuid}/services", + ), + "tfs_create_services": NbiRestToolHandler( + "POST", "/context/{context_uuid}/services", + _list_body("services", "services"), + ), + "tfs_get_service": NbiRestToolHandler( + "GET", "/context/{context_uuid}/service/{service_uuid}", + ), + "tfs_update_service": NbiRestToolHandler( + "PUT", "/context/{context_uuid}/service/{service_uuid}", + _body("service"), + ), + "tfs_delete_service": NbiRestToolHandler( + "DELETE", "/context/{context_uuid}/service/{service_uuid}", + ), + "tfs_get_optical_service_allocation": NbiRestToolHandler( + "GET", "/context/{context_uuid}/service/{service_uuid}/optical_allocation", + ), + "tfs_verify_optical_service_allocation": VerifyOpticalServiceAllocationTool(), + + "tfs_list_slice_ids": NbiRestToolHandler( + "GET", "/context/{context_uuid}/slice_ids", + ), + "tfs_list_slices": NbiRestToolHandler( + "GET", "/context/{context_uuid}/slices", + ), + "tfs_create_slices": NbiRestToolHandler( + "POST", "/context/{context_uuid}/slices", + _list_body("slices", "slices"), + ), + "tfs_get_slice": NbiRestToolHandler( + "GET", "/context/{context_uuid}/slice/{slice_uuid}", + ), + "tfs_update_slice": NbiRestToolHandler( + "PUT", "/context/{context_uuid}/slice/{slice_uuid}", + _body("slice"), + ), + "tfs_delete_slice": NbiRestToolHandler( + "DELETE", "/context/{context_uuid}/slice/{slice_uuid}", + ), + + "tfs_list_device_ids": NbiRestToolHandler( + "GET", "/device_ids", + ), + "tfs_list_devices": NbiRestToolHandler( + "GET", "/devices", + ), + "tfs_add_devices": NbiRestToolHandler( + "POST", "/devices", + _list_body("devices", "devices"), + ), + "tfs_get_device": NbiRestToolHandler( + "GET", "/device/{device_uuid}", + ), + "tfs_configure_device": NbiRestToolHandler( + "PUT", "/device/{device_uuid}", + _body("device"), + ), + "tfs_delete_device": NbiRestToolHandler( + "DELETE", "/device/{device_uuid}", + ), + + "tfs_list_link_ids": NbiRestToolHandler( + "GET", "/link_ids", + ), + "tfs_list_links": NbiRestToolHandler( + "GET", "/links", + ), + "tfs_set_links": NbiRestToolHandler( + "POST", "/links", + _list_body("links", "links"), + ), + "tfs_get_link": NbiRestToolHandler( + "GET", "/link/{link_uuid}", + ), + "tfs_set_link": NbiRestToolHandler( + "PUT", "/link/{link_uuid}", + _body("link"), + ), + "tfs_delete_link": NbiRestToolHandler( + "DELETE", "/link/{link_uuid}", + ), + "tfs_list_optical_link_ids": ListOpticalLinkIdsTool(), + "tfs_list_optical_links": NbiRestToolHandler( + "GET", "/optical_links", + ), + "tfs_get_optical_link": NbiRestToolHandler( + "GET", "/optical_link/{link_uuid}", + ), + "tfs_compute_optical_connectivity_candidates": NbiRestToolHandler( + "POST", + "/context/{context_uuid}/topology/{topology_uuid}/optical_connectivity_candidates", + _body("request"), + ), + + "tfs_list_optical_spectrum_reservations": NbiRestToolHandler( + "GET", "/context/{context_uuid}/optical_spectrum_reservations", + ), + "tfs_get_optical_spectrum_reservation": NbiRestToolHandler( + "GET", "/context/{context_uuid}/optical_spectrum_reservation/{reservation_uuid}", + ), + "tfs_create_optical_spectrum_reservation": NbiRestToolHandler( + "POST", "/context/{context_uuid}/optical_spectrum_reservations", + _body("reservation"), + ), + "tfs_update_optical_spectrum_reservation": NbiRestToolHandler( + "PUT", "/context/{context_uuid}/optical_spectrum_reservation/{reservation_uuid}", + _body("reservation"), + ), + "tfs_consume_optical_spectrum_reservation": NbiRestToolHandler( + "POST", "/context/{context_uuid}/optical_spectrum_reservation/{reservation_uuid}/consume", + _body("reservation"), + ), + "tfs_release_optical_spectrum_reservation": NbiRestToolHandler( + "POST", "/context/{context_uuid}/optical_spectrum_reservation/{reservation_uuid}/release", + ), + "tfs_delete_optical_spectrum_reservation": NbiRestToolHandler( + "DELETE", "/context/{context_uuid}/optical_spectrum_reservation/{reservation_uuid}", + ), + + "tfs_list_connection_ids": NbiRestToolHandler( + "GET", "/context/{context_uuid}/service/{service_uuid}/connection_ids", + ), + "tfs_list_connections": NbiRestToolHandler( + "GET", "/context/{context_uuid}/service/{service_uuid}/connections", + ), + "tfs_list_all_connections": ListAllConnectionsTool(), + "tfs_get_connection": NbiRestToolHandler( + "GET", "/connection/{connection_uuid}", + ), + + "tfs_list_policyrule_ids": NbiRestToolHandler( + "GET", "/policyrule_ids", + ), + "tfs_list_policyrules": NbiRestToolHandler( + "GET", "/policyrules", + ), + "tfs_get_policyrule": NbiRestToolHandler( + "GET", "/policyrule/{policyrule_uuid}", + ), + "tfs_health_check": NbiRestToolHandler( + "GET", "/healthz", + use_prefix=False, + ), + + "tfs_optical_slice_create": OpticalSliceCreateTool(), + "tfs_optical_slice_delete": NbiRestToolHandler( + "DELETE", "/restconf/optical-slice/v1/service/{slice_id}", + use_prefix=False, + ), + "tfs_media_channel_allocate": NbiRestToolHandler( + "POST", "/restconf/media-channel/v1/service/{allocation_id}", + _body("media_channel"), + use_prefix=False, + ), + "tfs_media_channel_release": NbiRestToolHandler( + "DELETE", "/restconf/media-channel/v1/service/{allocation_id}", + use_prefix=False, + ), + "tfs_dscm_oc_get": DscmOpenConfigTool("GET"), + "tfs_dscm_oc_post": DscmOpenConfigTool("POST", "config"), + "tfs_dscm_oc_delete": DscmOpenConfigTool("DELETE"), +} + + +def get_tool_handler(name: str) -> ToolHandler: + return TOOL_REGISTRY.get(name, UnknownTool(name)) diff --git a/src/mcp_server/service/tools/__init__.py b/src/mcp_server/service/tools/__init__.py new file mode 100644 index 000000000..b53987a4e --- /dev/null +++ b/src/mcp_server/service/tools/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/src/mcp_server/tests/__init__.py b/src/mcp_server/tests/__init__.py new file mode 100644 index 000000000..b53987a4e --- /dev/null +++ b/src/mcp_server/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/src/mcp_server/tests/fixtures/dummy_contexts.json b/src/mcp_server/tests/fixtures/dummy_contexts.json new file mode 100644 index 000000000..93cd60469 --- /dev/null +++ b/src/mcp_server/tests/fixtures/dummy_contexts.json @@ -0,0 +1,119 @@ +{ + "contexts": [ + { + "context_id": { + "context_uuid": { + "uuid": "admin" + } + }, + "name": "admin", + "topology_ids": [ + { + "context_id": { + "context_uuid": { + "uuid": "admin" + } + }, + "topology_uuid": { + "uuid": "admin" + } + } + ], + "service_ids": [], + "slice_ids": [] + } + ], + "topologies": [ + { + "topology_id": { + "context_id": { + "context_uuid": { + "uuid": "admin" + } + }, + "topology_uuid": { + "uuid": "admin" + } + }, + "name": "admin", + "device_ids": [ + { + "device_uuid": { + "uuid": "R1" + } + }, + { + "device_uuid": { + "uuid": "R2" + } + } + ], + "link_ids": [ + { + "link_uuid": { + "uuid": "R1/1==R2/1" + } + } + ], + "optical_link_ids": [] + } + ], + "devices": [ + { + "device_id": { + "device_uuid": { + "uuid": "R1" + } + }, + "name": "R1", + "device_type": "emu-packet-router", + "device_operational_status": "DEVICEOPERATIONALSTATUS_ENABLED" + }, + { + "device_id": { + "device_uuid": { + "uuid": "R2" + } + }, + "name": "R2", + "device_type": "emu-packet-router", + "device_operational_status": "DEVICEOPERATIONALSTATUS_ENABLED" + } + ], + "links": [ + { + "link_id": { + "link_uuid": { + "uuid": "R1/1==R2/1" + } + }, + "name": "R1/1==R2/1", + "link_endpoint_ids": [ + { + "device_id": { + "device_uuid": { + "uuid": "R1" + } + }, + "endpoint_uuid": { + "uuid": "1" + } + }, + { + "device_id": { + "device_uuid": { + "uuid": "R2" + } + }, + "endpoint_uuid": { + "uuid": "1" + } + } + ] + } + ], + "optical_links": [], + "services": [], + "connections": [], + "optical_spectrum_reservations": [] +} diff --git a/src/mcp_server/tests/test_unitary.py b/src/mcp_server/tests/test_unitary.py new file mode 100644 index 000000000..112fa6b73 --- /dev/null +++ b/src/mcp_server/tests/test_unitary.py @@ -0,0 +1,202 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import json +import sys +import types + + +class FakeMcpServer: + def __init__(self, name): + self.name = name + + def list_tools(self): + def decorator(function): + return function + return decorator + + def call_tool(self): + def decorator(function): + return function + return decorator + + +class FakeSseServerTransport: + def __init__(self, path): + self.path = path + + +def fake_stdio_server(): + return None + + +class FakeTextContent: + def __init__(self, type, text): # pylint: disable=redefined-builtin + self.type = type + self.text = text + + +class FakeTool: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + +def _install_dependency_fakes(): + mcp_module = types.ModuleType("mcp") + mcp_server_package = types.ModuleType("mcp.server") + mcp_server_sse_module = types.ModuleType("mcp.server.sse") + mcp_server_stdio_module = types.ModuleType("mcp.server.stdio") + mcp_types_module = types.ModuleType("mcp.types") + httpx_module = types.ModuleType("httpx") + + mcp_server_package.Server = FakeMcpServer + mcp_server_sse_module.SseServerTransport = FakeSseServerTransport + mcp_server_stdio_module.stdio_server = fake_stdio_server + mcp_types_module.TextContent = FakeTextContent + mcp_types_module.Tool = FakeTool + + class TimeoutException(Exception): + pass + + class HTTPStatusError(Exception): + pass + + httpx_module.TimeoutException = TimeoutException + httpx_module.HTTPStatusError = HTTPStatusError + httpx_module.AsyncClient = object + + sys.modules.setdefault("mcp", mcp_module) + sys.modules.setdefault("mcp.server", mcp_server_package) + sys.modules.setdefault("mcp.server.sse", mcp_server_sse_module) + sys.modules.setdefault("mcp.server.stdio", mcp_server_stdio_module) + sys.modules.setdefault("mcp.types", mcp_types_module) + sys.modules.setdefault("httpx", httpx_module) + + +_install_dependency_fakes() + +from mcp_server.service.McpServer import TfsMcpServer, verify_optical_allocation # pylint: disable=wrong-import-position + + +class CaptureTfsMcpServer(TfsMcpServer): + def __init__(self): + super().__init__( + transport="stdio", + api_url="http://nbiservice:8080", + api_prefix="/tfs-api", + ) + self.calls = [] + + async def api_request(self, method, endpoint, params=None, json_data=None, timeout=60.0, use_prefix=True): + self.calls.append({ + "method": method, + "endpoint": endpoint, + "params": params, + "json_data": json_data, + "timeout": timeout, + "use_prefix": use_prefix, + }) + return {"status": "ok", "endpoint": endpoint} + + +def _tool_result(server, name, args=None): + return json.loads(asyncio.run(server._handle_tool_call(name, args or {}))) + + +def test_list_contexts_maps_to_nbi_endpoint(): + server = CaptureTfsMcpServer() + + reply = _tool_result(server, "tfs_list_contexts") + + assert reply["status"] == "ok" + assert server.calls[-1]["method"] == "GET" + assert server.calls[-1]["endpoint"] == "/contexts" + assert server.calls[-1]["use_prefix"] is True + + +def test_create_optical_spectrum_reservation_maps_body(): + server = CaptureTfsMcpServer() + reservation = {"reservation_id": {"reservation_uuid": {"uuid": "res-1"}}} + + reply = _tool_result(server, "tfs_create_optical_spectrum_reservation", { + "context_uuid": "admin", + "reservation": reservation, + }) + + assert reply["status"] == "ok" + assert server.calls[-1]["method"] == "POST" + assert server.calls[-1]["endpoint"] == "/context/admin/optical_spectrum_reservations" + assert server.calls[-1]["json_data"] == reservation + + +def test_health_check_bypasses_tfs_api_prefix(): + server = CaptureTfsMcpServer() + + reply = _tool_result(server, "tfs_health_check") + + assert reply["status"] == "ok" + assert server.calls[-1]["endpoint"] == "/healthz" + assert server.calls[-1]["use_prefix"] is False + + +def test_verify_optical_allocation_accepts_active_matching_slots(): + reply = verify_optical_allocation( + { + "service_status": {"service_status": "SERVICESTATUS_ACTIVE"}, + "band_type": "C_BAND", + "slots": [4, 1, 2, 3], + }, + expected_band="c_slots", + expected_n_start=1, + expected_n_end=4, + ) + + assert reply["ok"] is True + assert reply["match"] is True + assert reply["effective_slots"] == [1, 2, 3, 4] + + +def test_verify_optical_allocation_rejects_inactive_service(): + reply = verify_optical_allocation( + { + "service_status": {"service_status": "SERVICESTATUS_PLANNED"}, + "band_type": "c_slots", + "slots": [1, 2, 3, 4], + }, + expected_band="c_slots", + expected_n_start=1, + expected_n_end=4, + ) + + assert reply["ok"] is False + assert "service is not ACTIVE" in reply["reason"] + + +def test_optical_slice_payload_adds_missing_context_uuid(): + payload = {"data": {"tapi-common:context": {}}} + + normalized = TfsMcpServer.normalize_optical_slice_payload(payload) + + assert normalized is not payload + assert normalized["data"]["tapi-common:context"]["uuid"] + + +def test_mocked_mode_loads_default_fixture(): + server = TfsMcpServer(transport="stdio", mode="mocked-tfs") + + reply = _tool_result(server, "tfs_list_contexts") + + assert reply["contexts"][0]["name"] == "admin" -- GitLab From b49dea14147f9a24b9546d77cab7919e49838e6a Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Fri, 26 Jun 2026 12:56:49 +0000 Subject: [PATCH 2/2] MCP Server component: - Fix SSE message endpoint - Update transport initialization --- src/mcp_server/service/McpServer.py | 4 +++- src/mcp_server/tests/test_unitary.py | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/mcp_server/service/McpServer.py b/src/mcp_server/service/McpServer.py index 473476f9c..c390c9d29 100644 --- a/src/mcp_server/service/McpServer.py +++ b/src/mcp_server/service/McpServer.py @@ -31,6 +31,8 @@ from .tools.Definitions import TOOLS from .tools.OpticalAllocation import verify_optical_allocation from .tools.Registry import get_tool_handler +SSE_MESSAGE_ENDPOINT = "/mcp/messages/" + class TfsMcpServer: """MCP Server bridging an MCP client to the TFS NBI REST API.""" @@ -305,7 +307,7 @@ class TfsMcpServer: from starlette.routing import Mount, Route import uvicorn - sse_transport = SseServerTransport("/messages/") + sse_transport = SseServerTransport(SSE_MESSAGE_ENDPOINT) async def handle_sse(request): async with sse_transport.connect_sse( diff --git a/src/mcp_server/tests/test_unitary.py b/src/mcp_server/tests/test_unitary.py index 112fa6b73..41b9216b2 100644 --- a/src/mcp_server/tests/test_unitary.py +++ b/src/mcp_server/tests/test_unitary.py @@ -88,7 +88,9 @@ def _install_dependency_fakes(): _install_dependency_fakes() -from mcp_server.service.McpServer import TfsMcpServer, verify_optical_allocation # pylint: disable=wrong-import-position +from mcp_server.service.McpServer import ( # pylint: disable=wrong-import-position + SSE_MESSAGE_ENDPOINT, TfsMcpServer, verify_optical_allocation, +) class CaptureTfsMcpServer(TfsMcpServer): @@ -200,3 +202,9 @@ def test_mocked_mode_loads_default_fixture(): reply = _tool_result(server, "tfs_list_contexts") assert reply["contexts"][0]["name"] == "admin" + + +def test_sse_transport_advertises_mcp_message_endpoint(): + transport = FakeSseServerTransport(SSE_MESSAGE_ENDPOINT) + + assert transport.path == "/mcp/messages/" -- GitLab