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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,47 @@ The following properties can be configured for the VISO TRUST API:

For information on how to generate an API token for the `visotrust.api.token` environment variable, see the [VISO TRUST support documentation](https://support.visotrust.com/article/olo26aapun-generateaccesstoken).

### Application Profiles

This application supports Spring Boot profiles to enable different configurations for different deployment scenarios.

#### Remote Profile

The `remote` profile is specifically designed for **remote MCP support using Server-Sent Events (SSE)**. This profile configures the application to work optimally in distributed environments where the MCP server needs to communicate with remote clients over HTTP/SSE connections.

**Key differences in the remote profile:**
- Configured for SSE-based communication instead of standard I/O
- Optimized server settings for remote client connections
- Enhanced logging for distributed debugging

**How to activate the remote profile:**

When running with Java directly:
```bash
java -jar viso-mcp-server-<version>.jar --spring.profiles.active=remote
```

When running with Gradle:
```bash
./gradlew bootRun --args="--spring.profiles.active=remote"
```

When using Docker:
```bash
docker run -i --rm \
-e VISOTRUST_API_TOKEN=<your-api-token> \
-e SPRING_PROFILES_ACTIVE=remote \
viso-mcp-server
```

**When to use the remote profile:**
- When deploying the MCP server to a remote server or cloud environment
- When clients will connect via HTTP/SSE rather than direct stdio
- When you need enhanced logging and monitoring for distributed deployments
- When integrating with web-based AI assistants that require SSE communication

For local development and direct stdio communication, use the default profile (no profile specification needed).

## Installation

### Quick Install
Expand Down
28 changes: 21 additions & 7 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.5'
id 'org.springframework.boot' version '3.5.5'
id 'io.spring.dependency-management' version '1.1.7'
id 'com.diffplug.spotless' version '6.25.0'
id 'com.diffplug.spotless' version '7.2.1'
id 'com.github.jakemarsden.git-hooks' version '0.0.2'
}

group = 'com.visotrust.viso'
version = '1.0.0'
version = '1.1.0'

java {
toolchain {
Expand All @@ -17,19 +17,33 @@ java {

repositories {
mavenCentral()
// Add Spring milestone and snapshot repositories for accessing SNAPSHOT versions
maven {
name = "Spring Milestones"
url = "https://repo.spring.io/milestone"
}
maven {
name = "Spring Snapshots"
url = "https://repo.spring.io/snapshot"
}
}

ext {
set('springAiVersion', "1.0.0-M8")
set('springAiVersion', "1.1.0-SNAPSHOT")
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'org.apache.httpcomponents.client5:httpclient5'

// Spring Security dependencies (without starter)
implementation 'org.springframework.security:spring-security-web'
implementation 'org.springframework.security:spring-security-config'
implementation 'org.springframework.security:spring-security-core'
implementation 'org.springframework.boot:spring-boot-starter-actuator'

developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
Expand Down Expand Up @@ -76,4 +90,4 @@ spotless {
// Configure Git hooks
gitHooks {
hooks = ['pre-commit': 'spotlessCheck']
}
}
2 changes: 1 addition & 1 deletion glama.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"maintainers": [
"dsarlo-viso"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbacks;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* Copyright (c) 2025 VISO TRUST */
package com.visotrust.viso.visomcpserver.config;

import com.visotrust.viso.visomcpserver.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
* Spring Security configuration for JWT-based authentication. Configured without using Spring Boot
* Security starter.
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;

public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}

/**
* Configures the authentication manager to prevent Spring Boot's auto-configuration of default
* user credentials.
*
* @param authConfig the authentication configuration
* @return configured AuthenticationManager
* @throws Exception if configuration fails
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig)
throws Exception {
return authConfig.getAuthenticationManager();
}

/**
* Configures the security filter chain to require JWT authentication for all endpoints.
*
* @param http the HttpSecurity configuration
* @return configured SecurityFilterChain
* @throws Exception if configuration fails
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
// Disable HTTP Basic authentication to prevent default user generation
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
auth ->
auth.requestMatchers("/actuator/health")
.permitAll()
.anyRequest()
.authenticated())
.addFilterBefore(
jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(
exceptions ->
exceptions
.authenticationEntryPoint(
(request, response, authException) -> {
response.sendError(
401,
"Unauthorized: Missing JWT token in Authorization header");
})
.accessDeniedHandler(
(request, response, accessDeniedException) -> {
response.sendError(
403, "Forbidden: Access denied");
}));

return http.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* Copyright (c) 2025 VISO TRUST */
package com.visotrust.viso.visomcpserver.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

/**
* JWT Authentication Filter that intercepts requests and extracts JWT tokens from the Authorization
* header. Sets up Spring Security context without validation. The JWT token is passed through to
* the API service for upstream processing.
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

try {
String jwt = extractJwtFromRequest(request);

if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
UUID.randomUUID(), jwt, new ArrayList<>());

authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));

SecurityContextHolder.getContext().setAuthentication(authentication);

logger.debug("JWT token extracted and set in security context");
}
} catch (Exception e) {
logger.error("Cannot set user authentication: {}", e.getMessage());
// Clear security context on error
SecurityContextHolder.clearContext();
}

// Continue with the filter chain
filterChain.doFilter(request, response);
}

/**
* Extracts JWT token from the Authorization header.
*
* @param request the HTTP request
* @return JWT token if present and valid format, null otherwise
*/
private static String extractJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,70 @@
package com.visotrust.viso.visomcpserver.service;

import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class ApiService {

private final Logger log = LoggerFactory.getLogger(ApiService.class);

private final RestTemplate restTemplate;
private final String baseUrl;
private final String apiToken;

public ApiService(
RestTemplate restTemplate,
@Value("${visotrust.api.base-url}") String baseUrl,
@Value("${visotrust.api.token}") String apiToken) {
@Value("${visotrust.api.token:#{null}}") String apiToken) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
this.apiToken = apiToken;
}

private HttpHeaders createHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiToken);

// Extract JWT token from Spring Security context, or fallback to static token
String tokenToUse = extractJwtFromSecurityContext();
if (tokenToUse == null) {
tokenToUse = apiToken;
log.info("No JWT token found in security context, using static API token");
} else {
log.info("Using JWT token from security context");
}

headers.set("Authorization", "Bearer " + tokenToUse);
return headers;
}

/**
* Extracts JWT token from Spring Security context.
*
* @return JWT token if available in security context, null otherwise
*/
private String extractJwtFromSecurityContext() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getCredentials() instanceof String) {
return (String) authentication.getCredentials();
}
} catch (Exception e) {
log.debug("Failed to extract JWT from security context: {}", e.getMessage());
}
return null;
}

public <T> T get(String path, Class<T> responseType) {
HttpEntity<?> entity = new HttpEntity<>(createHeaders());
ResponseEntity<T> response =
Expand Down
20 changes: 20 additions & 0 deletions src/main/resources/application-remote.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
spring.application.name=viso-mcp

# VISO TRUST API Configuration
visotrust.api.base-url=http://localhost:8080
visotrust.api.timeout=30000
visotrust.api.connect-timeout=5000

# Spring AI MCP Server Configuration
spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.name=viso-mcp-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=sync
spring.ai.mcp.server.stdio=false

spring.main.banner-mode=off
spring.main.web-application-type=servlet

logging.pattern.console=%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}

server.port=3001