mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Compare commits
13 Commits
codex/crea
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad70c23b3 | ||
|
|
016276dbc3 | ||
|
|
bd2d6e7485 | ||
|
|
df59a9fd4b | ||
|
|
2e70a3d273 | ||
|
|
3dc6935d19 | ||
|
|
779bb2db78 | ||
|
|
b3b0b194a3 | ||
|
|
e21b2f42d2 | ||
|
|
05a5acee7e | ||
|
|
755982098b | ||
|
|
af24263c0a | ||
|
|
8fd268bd11 |
@@ -2,6 +2,7 @@
|
||||
SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
OPENISLE_MCP_PORT=8085
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.CommentContextDto;
|
||||
import com.openisle.dto.CommentDto;
|
||||
import com.openisle.dto.CommentRequest;
|
||||
import com.openisle.dto.PostChangeLogDto;
|
||||
import com.openisle.dto.TimelineItemDto;
|
||||
import com.openisle.mapper.CommentMapper;
|
||||
import com.openisle.mapper.PostChangeLogMapper;
|
||||
import com.openisle.mapper.PostMapper;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.service.*;
|
||||
@@ -40,6 +42,7 @@ public class CommentController {
|
||||
private final PointService pointService;
|
||||
private final PostChangeLogService changeLogService;
|
||||
private final PostChangeLogMapper postChangeLogMapper;
|
||||
private final PostMapper postMapper;
|
||||
|
||||
@Value("${app.captcha.enabled:false}")
|
||||
private boolean captchaEnabled;
|
||||
@@ -184,6 +187,37 @@ public class CommentController {
|
||||
return itemDtoList;
|
||||
}
|
||||
|
||||
@GetMapping("/comments/{commentId}/context")
|
||||
@Operation(
|
||||
summary = "Comment context",
|
||||
description = "Get a comment along with its previous comments and related post"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Comment context",
|
||||
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
|
||||
)
|
||||
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
|
||||
log.debug("getCommentContext called for comment {}", commentId);
|
||||
Comment comment = commentService.getComment(commentId);
|
||||
CommentContextDto dto = new CommentContextDto();
|
||||
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
|
||||
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
|
||||
dto.setPreviousComments(
|
||||
commentService
|
||||
.getCommentsBefore(comment)
|
||||
.stream()
|
||||
.map(commentMapper::toDtoWithReplies)
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
log.debug(
|
||||
"getCommentContext returning {} previous comments for comment {}",
|
||||
dto.getPreviousComments().size(),
|
||||
commentId
|
||||
);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/comments/{id}")
|
||||
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||
|
||||
@@ -224,6 +224,26 @@ public class PostController {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/recent")
|
||||
@Operation(
|
||||
summary = "Recent posts",
|
||||
description = "List posts created within the specified number of minutes"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Recent posts",
|
||||
content = @Content(
|
||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||
)
|
||||
)
|
||||
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
|
||||
return postService
|
||||
.listRecentPosts(minutes)
|
||||
.stream()
|
||||
.map(postMapper::toSummaryDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/ranking")
|
||||
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
||||
@ApiResponse(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DTO representing the context of a comment including its post and previous comments.
|
||||
*/
|
||||
@Data
|
||||
public class CommentContextDto {
|
||||
|
||||
private PostSummaryDto post;
|
||||
private CommentDto targetComment;
|
||||
private List<CommentDto> previousComments;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.openisle.repository;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -10,6 +11,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
||||
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
||||
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||
Post post,
|
||||
LocalDateTime createdAt
|
||||
);
|
||||
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
||||
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
||||
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||
PostStatus status,
|
||||
LocalDateTime createdAt
|
||||
);
|
||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
||||
User author,
|
||||
PostStatus status,
|
||||
|
||||
@@ -266,6 +266,27 @@ public class CommentService {
|
||||
return replies;
|
||||
}
|
||||
|
||||
public Comment getComment(Long commentId) {
|
||||
log.debug("getComment called for id {}", commentId);
|
||||
return commentRepository
|
||||
.findById(commentId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||
}
|
||||
|
||||
public List<Comment> getCommentsBefore(Comment comment) {
|
||||
log.debug("getCommentsBefore called for comment {}", comment.getId());
|
||||
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||
comment.getPost(),
|
||||
comment.getCreatedAt()
|
||||
);
|
||||
log.debug(
|
||||
"getCommentsBefore returning {} comments for comment {}",
|
||||
comments.size(),
|
||||
comment.getId()
|
||||
);
|
||||
return comments;
|
||||
}
|
||||
|
||||
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
||||
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
||||
User user = userRepository
|
||||
|
||||
@@ -770,6 +770,18 @@ public class PostService {
|
||||
return listPostsByCategories(null, null, null);
|
||||
}
|
||||
|
||||
public List<Post> listRecentPosts(int minutes) {
|
||||
if (minutes <= 0) {
|
||||
throw new IllegalArgumentException("Minutes must be positive");
|
||||
}
|
||||
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
|
||||
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||
PostStatus.PUBLISHED,
|
||||
since
|
||||
);
|
||||
return sortByPinnedAndCreated(posts);
|
||||
}
|
||||
|
||||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||||
return listPostsByViews(null, null, page, pageSize);
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=production \
|
||||
frontend_service
|
||||
frontend_service mcp
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -39,12 +39,12 @@ echo "👉 Build images (staging)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=staging \
|
||||
frontend_service
|
||||
frontend_service mcp
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -178,6 +178,32 @@ services:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/mcp.Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
OPENISLE_MCP_BACKEND_BASE_URL: http://springboot:${SERVER_PORT:-8080}
|
||||
OPENISLE_MCP_HOST: 0.0.0.0
|
||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
|
||||
OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0}
|
||||
ports:
|
||||
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_started
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
|
||||
websocket-service:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
|
||||
|
||||
21
docker/mcp.Dockerfile
Normal file
21
docker/mcp.Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY mcp/pyproject.toml mcp/README.md ./
|
||||
COPY mcp/src ./src
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
ENV OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8085 \
|
||||
OPENISLE_MCP_TRANSPORT=streamable-http
|
||||
|
||||
EXPOSE 8085
|
||||
|
||||
CMD ["openisle-mcp"]
|
||||
|
||||
39
mcp/README.md
Normal file
39
mcp/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# OpenIsle MCP Server
|
||||
|
||||
This package provides a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server
|
||||
that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the
|
||||
global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and
|
||||
other resources.
|
||||
|
||||
## Configuration
|
||||
|
||||
The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `BACKEND_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend. |
|
||||
| `PORT` | `8085` | TCP port when running with the `streamable-http` transport. |
|
||||
| `HOST` | `0.0.0.0` | Interface to bind when serving HTTP. |
|
||||
| `TRANSPORT` | `streamable-http` | Transport to use (`stdio`, `sse`, or `streamable-http`). |
|
||||
| `REQUEST_TIMEOUT` | `10.0` | Timeout (seconds) for backend HTTP requests. |
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp
|
||||
```
|
||||
|
||||
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
|
||||
|
||||
## Available tools
|
||||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `search` | Perform a global search against the OpenIsle backend. |
|
||||
| `reply_to_comment` | Reply to an existing comment using a JWT token. |
|
||||
| `recent_posts` | Retrieve posts created within the last *N* minutes. |
|
||||
|
||||
The tools return structured data mirroring the backend DTOs, including highlighted snippets for
|
||||
search results, the full comment payload for replies, and detailed metadata for recent posts.
|
||||
|
||||
27
mcp/pyproject.toml
Normal file
27
mcp/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openisle-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Model Context Protocol server exposing OpenIsle search capabilities."
|
||||
readme = "README.md"
|
||||
authors = [{ name = "OpenIsle", email = "engineering@openisle.example" }]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp>=1.19.0",
|
||||
"httpx>=0.28,<0.29",
|
||||
"pydantic>=2.12,<3",
|
||||
"pydantic-settings>=2.11,<3"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.server:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
packages = ["src/openisle_mcp"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
6
mcp/src/openisle_mcp/__init__.py
Normal file
6
mcp/src/openisle_mcp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""OpenIsle MCP server package."""
|
||||
|
||||
from .config import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
|
||||
52
mcp/src/openisle_mcp/config.py
Normal file
52
mcp/src/openisle_mcp/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Application configuration helpers for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.networks import AnyHttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration for the MCP server."""
|
||||
|
||||
backend_base_url: AnyHttpUrl = Field(
|
||||
"http://springboot:8080",
|
||||
description="Base URL for the OpenIsle backend service.",
|
||||
)
|
||||
host: str = Field(
|
||||
"0.0.0.0",
|
||||
description="Host interface to bind when running with HTTP transports.",
|
||||
)
|
||||
port: int = Field(
|
||||
8085,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="TCP port for HTTP transports.",
|
||||
)
|
||||
transport: Literal["stdio", "sse", "streamable-http"] = Field(
|
||||
"streamable-http",
|
||||
description="MCP transport to use when running the server.",
|
||||
)
|
||||
request_timeout: float = Field(
|
||||
10.0,
|
||||
gt=0,
|
||||
description="Timeout (seconds) for backend search requests.",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="OPENISLE_MCP_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached application settings."""
|
||||
|
||||
return Settings()
|
||||
262
mcp/src/openisle_mcp/schemas.py
Normal file
262
mcp/src/openisle_mcp/schemas.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Pydantic models describing tool inputs and outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result entry."""
|
||||
|
||||
type: str = Field(description="Entity type for the result (post, user, tag, etc.).")
|
||||
id: Optional[int] = Field(default=None, description="Identifier of the matched entity.")
|
||||
text: Optional[str] = Field(default=None, description="Primary text associated with the result.")
|
||||
sub_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="subText",
|
||||
description="Secondary text, e.g. a username or excerpt.",
|
||||
)
|
||||
extra: Optional[str] = Field(default=None, description="Additional contextual information.")
|
||||
post_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="postId",
|
||||
description="Associated post identifier when relevant.",
|
||||
)
|
||||
highlighted_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedText",
|
||||
description="Highlighted snippet of the primary text if available.",
|
||||
)
|
||||
highlighted_sub_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedSubText",
|
||||
description="Highlighted snippet of the secondary text if available.",
|
||||
)
|
||||
highlighted_extra: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedExtra",
|
||||
description="Highlighted snippet of extra information if available.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Structured response returned by the search tool."""
|
||||
|
||||
keyword: str = Field(description="The keyword that was searched.")
|
||||
total: int = Field(description="Total number of matches returned by the backend.")
|
||||
results: list[SearchResultItem] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered collection of search results.",
|
||||
)
|
||||
|
||||
|
||||
class AuthorInfo(BaseModel):
|
||||
"""Summary of a post or comment author."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Author identifier.")
|
||||
username: Optional[str] = Field(default=None, description="Author username.")
|
||||
avatar: Optional[str] = Field(default=None, description="URL of the author's avatar.")
|
||||
display_medal: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="displayMedal",
|
||||
description="Medal displayed next to the author, when available.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class CategoryInfo(BaseModel):
|
||||
"""Basic information about a post category."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Category identifier.")
|
||||
name: Optional[str] = Field(default=None, description="Category name.")
|
||||
description: Optional[str] = Field(
|
||||
default=None, description="Human friendly description of the category."
|
||||
)
|
||||
icon: Optional[str] = Field(default=None, description="Icon URL associated with the category.")
|
||||
small_icon: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="smallIcon",
|
||||
description="Compact icon URL for the category.",
|
||||
)
|
||||
count: Optional[int] = Field(default=None, description="Number of posts within the category.")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class TagInfo(BaseModel):
|
||||
"""Details for a tag assigned to a post."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Tag identifier.")
|
||||
name: Optional[str] = Field(default=None, description="Tag name.")
|
||||
description: Optional[str] = Field(default=None, description="Description of the tag.")
|
||||
icon: Optional[str] = Field(default=None, description="Icon URL for the tag.")
|
||||
small_icon: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="smallIcon",
|
||||
description="Compact icon URL for the tag.",
|
||||
)
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="createdAt",
|
||||
description="When the tag was created.",
|
||||
)
|
||||
count: Optional[int] = Field(default=None, description="Number of posts using the tag.")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class ReactionInfo(BaseModel):
|
||||
"""Representation of a reaction on a post or comment."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Reaction identifier.")
|
||||
type: Optional[str] = Field(default=None, description="Reaction type (emoji, like, etc.).")
|
||||
user: Optional[str] = Field(default=None, description="Username of the reacting user.")
|
||||
post_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="postId",
|
||||
description="Related post identifier when applicable.",
|
||||
)
|
||||
comment_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="commentId",
|
||||
description="Related comment identifier when applicable.",
|
||||
)
|
||||
message_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="messageId",
|
||||
description="Related message identifier when applicable.",
|
||||
)
|
||||
reward: Optional[int] = Field(default=None, description="Reward granted for the reaction, if any.")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class CommentData(BaseModel):
|
||||
"""Comment information returned by the backend."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Comment identifier.")
|
||||
content: Optional[str] = Field(default=None, description="Markdown content of the comment.")
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="createdAt",
|
||||
description="Timestamp when the comment was created.",
|
||||
)
|
||||
pinned_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="pinnedAt",
|
||||
description="Timestamp when the comment was pinned, if applicable.",
|
||||
)
|
||||
author: Optional[AuthorInfo] = Field(default=None, description="Author of the comment.")
|
||||
replies: list["CommentData"] = Field(
|
||||
default_factory=list,
|
||||
description="Nested replies associated with the comment.",
|
||||
)
|
||||
reactions: list[ReactionInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Reactions applied to the comment.",
|
||||
)
|
||||
reward: Optional[int] = Field(default=None, description="Reward gained by posting the comment.")
|
||||
point_reward: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="pointReward",
|
||||
description="Points rewarded for the comment.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class CommentReplyResult(BaseModel):
|
||||
"""Structured response returned when replying to a comment."""
|
||||
|
||||
comment: CommentData = Field(description="Reply comment returned by the backend.")
|
||||
|
||||
|
||||
class PostSummary(BaseModel):
|
||||
"""Summary information for a post."""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="Post identifier.")
|
||||
title: Optional[str] = Field(default=None, description="Title of the post.")
|
||||
content: Optional[str] = Field(default=None, description="Excerpt or content of the post.")
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="createdAt",
|
||||
description="When the post was created.",
|
||||
)
|
||||
author: Optional[AuthorInfo] = Field(default=None, description="Author who created the post.")
|
||||
category: Optional[CategoryInfo] = Field(default=None, description="Category of the post.")
|
||||
tags: list[TagInfo] = Field(default_factory=list, description="Tags assigned to the post.")
|
||||
views: Optional[int] = Field(default=None, description="Total view count for the post.")
|
||||
comment_count: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="commentCount",
|
||||
description="Number of comments on the post.",
|
||||
)
|
||||
status: Optional[str] = Field(default=None, description="Workflow status of the post.")
|
||||
pinned_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="pinnedAt",
|
||||
description="When the post was pinned, if ever.",
|
||||
)
|
||||
last_reply_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
alias="lastReplyAt",
|
||||
description="Timestamp of the most recent reply.",
|
||||
)
|
||||
reactions: list[ReactionInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Reactions received by the post.",
|
||||
)
|
||||
participants: list[AuthorInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Users participating in the discussion.",
|
||||
)
|
||||
subscribed: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Whether the current user is subscribed to the post.",
|
||||
)
|
||||
reward: Optional[int] = Field(default=None, description="Reward granted for the post.")
|
||||
point_reward: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="pointReward",
|
||||
description="Points granted for the post.",
|
||||
)
|
||||
type: Optional[str] = Field(default=None, description="Type of the post.")
|
||||
lottery: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Lottery information for the post."
|
||||
)
|
||||
poll: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Poll information for the post."
|
||||
)
|
||||
rss_excluded: Optional[bool] = Field(
|
||||
default=None,
|
||||
alias="rssExcluded",
|
||||
description="Whether the post is excluded from RSS feeds.",
|
||||
)
|
||||
closed: Optional[bool] = Field(default=None, description="Whether the post is closed for replies.")
|
||||
visible_scope: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="visibleScope",
|
||||
description="Visibility scope configuration for the post.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class RecentPostsResponse(BaseModel):
|
||||
"""Structured response for the recent posts tool."""
|
||||
|
||||
minutes: int = Field(description="Time window, in minutes, used for the query.")
|
||||
total: int = Field(description="Number of posts returned by the backend.")
|
||||
posts: list[PostSummary] = Field(
|
||||
default_factory=list,
|
||||
description="Posts created within the requested time window.",
|
||||
)
|
||||
|
||||
|
||||
CommentData.model_rebuild()
|
||||
98
mcp/src/openisle_mcp/search_client.py
Normal file
98
mcp/src/openisle_mcp/search_client.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""HTTP client helpers for talking to the OpenIsle backend endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""Client for calling the OpenIsle HTTP APIs used by the MCP server."""
|
||||
|
||||
def __init__(self, base_url: str, *, timeout: float = 10.0) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout)
|
||||
return self._client
|
||||
|
||||
async def global_search(self, keyword: str) -> list[dict[str, Any]]:
|
||||
"""Call the global search endpoint and return the parsed JSON payload."""
|
||||
|
||||
client = self._get_client()
|
||||
response = await client.get(
|
||||
"/api/search/global",
|
||||
params={"keyword": keyword},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||
raise ValueError(f"Unexpected response format from search endpoint: {formatted}")
|
||||
return [self._ensure_dict(entry) for entry in payload]
|
||||
|
||||
async def reply_to_comment(
|
||||
self,
|
||||
comment_id: int,
|
||||
token: str,
|
||||
content: str,
|
||||
captcha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reply to an existing comment and return the created reply."""
|
||||
|
||||
client = self._get_client()
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
payload: dict[str, Any] = {"content": content}
|
||||
if captcha is not None:
|
||||
stripped_captcha = captcha.strip()
|
||||
if stripped_captcha:
|
||||
payload["captcha"] = stripped_captcha
|
||||
|
||||
response = await client.post(
|
||||
f"/api/comments/{comment_id}/replies",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return self._ensure_dict(response.json())
|
||||
|
||||
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
|
||||
"""Return posts created within the given timeframe."""
|
||||
|
||||
client = self._get_client()
|
||||
response = await client.get(
|
||||
"/api/posts/recent",
|
||||
params={"minutes": minutes},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||
raise ValueError(
|
||||
f"Unexpected response format from recent posts endpoint: {formatted}"
|
||||
)
|
||||
return [self._ensure_dict(entry) for entry in payload]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Dispose of the underlying HTTP client."""
|
||||
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
@staticmethod
|
||||
def _ensure_dict(entry: Any) -> dict[str, Any]:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"Expected JSON object, got: {type(entry)!r}")
|
||||
return entry
|
||||
240
mcp/src/openisle_mcp/server.py
Normal file
240
mcp/src/openisle_mcp/server.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Entry point for running the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from pydantic import ValidationError
|
||||
from pydantic import Field as PydanticField
|
||||
|
||||
from .config import get_settings
|
||||
from .schemas import (
|
||||
CommentData,
|
||||
CommentReplyResult,
|
||||
PostSummary,
|
||||
RecentPostsResponse,
|
||||
SearchResponse,
|
||||
SearchResultItem,
|
||||
)
|
||||
from .search_client import SearchClient
|
||||
|
||||
settings = get_settings()
|
||||
search_client = SearchClient(
|
||||
str(settings.backend_base_url), timeout=settings.request_timeout
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastMCP):
|
||||
"""Lifecycle hook that disposes shared resources when the server stops."""
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await search_client.aclose()
|
||||
|
||||
|
||||
app = FastMCP(
|
||||
name="openisle-mcp",
|
||||
instructions=(
|
||||
"Use this server to search OpenIsle content, reply to comments with an authentication "
|
||||
"token, and list posts created within a recent time window."
|
||||
),
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.tool(
|
||||
name="search",
|
||||
description="Perform a global search across OpenIsle resources.",
|
||||
structured_output=True,
|
||||
)
|
||||
async def search(
|
||||
keyword: Annotated[str, PydanticField(description="Keyword to search for.")],
|
||||
ctx: Context | None = None,
|
||||
) -> SearchResponse:
|
||||
"""Call the OpenIsle global search endpoint and return structured results."""
|
||||
|
||||
sanitized = keyword.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("Keyword must not be empty.")
|
||||
|
||||
try:
|
||||
raw_results = await search_client.global_search(sanitized)
|
||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||
message = (
|
||||
"OpenIsle backend returned HTTP "
|
||||
f"{exc.response.status_code} while searching for '{sanitized}'."
|
||||
)
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||
message = f"Unable to reach OpenIsle backend search service: {exc}."
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
try:
|
||||
results = [SearchResultItem.model_validate(entry) for entry in raw_results]
|
||||
except ValidationError as exc:
|
||||
message = "Received malformed data from the OpenIsle backend search endpoint."
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
if ctx is not None:
|
||||
await ctx.info(f"Search keyword '{sanitized}' returned {len(results)} results.")
|
||||
|
||||
return SearchResponse(keyword=sanitized, total=len(results), results=results)
|
||||
|
||||
|
||||
@app.tool(
|
||||
name="reply_to_comment",
|
||||
description="Reply to an existing comment using an authentication token.",
|
||||
structured_output=True,
|
||||
)
|
||||
async def reply_to_comment(
|
||||
comment_id: Annotated[
|
||||
int,
|
||||
PydanticField(ge=1, description="Identifier of the comment being replied to."),
|
||||
],
|
||||
token: Annotated[str, PydanticField(description="JWT bearer token for the user performing the reply.")],
|
||||
content: Annotated[
|
||||
str,
|
||||
PydanticField(description="Markdown content of the reply."),
|
||||
],
|
||||
captcha: Annotated[
|
||||
str | None,
|
||||
PydanticField(
|
||||
default=None,
|
||||
description="Optional captcha solution if the backend requires it.",
|
||||
),
|
||||
] = None,
|
||||
ctx: Context | None = None,
|
||||
) -> CommentReplyResult:
|
||||
"""Create a reply for a comment and return the backend payload."""
|
||||
|
||||
sanitized_content = content.strip()
|
||||
if not sanitized_content:
|
||||
raise ValueError("Reply content must not be empty.")
|
||||
|
||||
sanitized_token = token.strip()
|
||||
if not sanitized_token:
|
||||
raise ValueError("Authentication token must not be empty.")
|
||||
|
||||
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||
|
||||
try:
|
||||
raw_comment = await search_client.reply_to_comment(
|
||||
comment_id,
|
||||
sanitized_token,
|
||||
sanitized_content,
|
||||
sanitized_captcha,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||
status_code = exc.response.status_code
|
||||
if status_code == 401:
|
||||
message = (
|
||||
"Authentication failed while replying to comment "
|
||||
f"{comment_id}. Please verify the token."
|
||||
)
|
||||
elif status_code == 403:
|
||||
message = (
|
||||
"The provided token is not authorized to reply to comment "
|
||||
f"{comment_id}."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"OpenIsle backend returned HTTP "
|
||||
f"{status_code} while replying to comment {comment_id}."
|
||||
)
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||
message = (
|
||||
"Unable to reach OpenIsle backend comment service: "
|
||||
f"{exc}."
|
||||
)
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
try:
|
||||
comment = CommentData.model_validate(raw_comment)
|
||||
except ValidationError as exc:
|
||||
message = "Received malformed data from the reply comment endpoint."
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
if ctx is not None:
|
||||
await ctx.info(
|
||||
"Reply created successfully for comment "
|
||||
f"{comment_id}."
|
||||
)
|
||||
|
||||
return CommentReplyResult(comment=comment)
|
||||
|
||||
|
||||
@app.tool(
|
||||
name="recent_posts",
|
||||
description="Retrieve posts created in the last N minutes.",
|
||||
structured_output=True,
|
||||
)
|
||||
async def recent_posts(
|
||||
minutes: Annotated[
|
||||
int,
|
||||
PydanticField(gt=0, le=1440, description="Time window in minutes to search for new posts."),
|
||||
],
|
||||
ctx: Context | None = None,
|
||||
) -> RecentPostsResponse:
|
||||
"""Fetch recent posts from the backend and return structured data."""
|
||||
|
||||
try:
|
||||
raw_posts = await search_client.recent_posts(minutes)
|
||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||
message = (
|
||||
"OpenIsle backend returned HTTP "
|
||||
f"{exc.response.status_code} while fetching recent posts for the last {minutes} minutes."
|
||||
)
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||
message = f"Unable to reach OpenIsle backend recent posts service: {exc}."
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
try:
|
||||
posts = [PostSummary.model_validate(entry) for entry in raw_posts]
|
||||
except ValidationError as exc:
|
||||
message = "Received malformed data from the recent posts endpoint."
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
if ctx is not None:
|
||||
await ctx.info(
|
||||
f"Found {len(posts)} posts created within the last {minutes} minutes."
|
||||
)
|
||||
|
||||
return RecentPostsResponse(minutes=minutes, total=len(posts), posts=posts)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the MCP server using the configured transport."""
|
||||
|
||||
app.run(transport=settings.transport)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual execution
|
||||
main()
|
||||
|
||||
@@ -100,10 +100,28 @@ server {
|
||||
# auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
}
|
||||
|
||||
|
||||
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
|
||||
location ^~ /websocket/ {
|
||||
proxy_pass http://127.0.0.1:8084/;
|
||||
proxy_pass http://127.0.0.1:8084/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
@@ -8,11 +8,8 @@ server {
|
||||
listen 443 ssl;
|
||||
server_name staging.open-isle.com www.staging.open-isle.com;
|
||||
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
|
||||
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@@ -40,59 +37,13 @@ server {
|
||||
add_header X-Upstream $upstream_addr always;
|
||||
}
|
||||
|
||||
# 1) 原生 WebSocket
|
||||
location ^~ /api/ws {
|
||||
proxy_pass http://127.0.0.1:8081; # 不要尾随 /,保留原样 URI
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# 升级所需
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# 2) SockJS(包含 /info、/iframe.html、/.../websocket 等)
|
||||
location ^~ /api/sockjs {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin)
|
||||
# proxy_hide_header X-Frame-Options;
|
||||
# add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# ---------- API ----------
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8081/api/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -109,7 +60,6 @@ server {
|
||||
proxy_cache_bypass 1;
|
||||
}
|
||||
|
||||
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
|
||||
location ^~ /websocket/ {
|
||||
proxy_pass http://127.0.0.1:8083/;
|
||||
proxy_http_version 1.1;
|
||||
@@ -130,4 +80,24 @@ server {
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
}
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:8086;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user