Add initial implementation of MCP server and pgvecter API integration

- Introduced the MCP server with core functionality for handling document upserts and searches using the pgvecter API.
- Added configuration management for API keys and base URL.
- Implemented RPC request handling for various tools, including dev log and trade event management.
- Expanded the README with endpoint details and usage examples for the new MCP server features.
- Created documentation for development notes and API specifications in `docs/mcp_dev_memo.md`.
- Added necessary types and client implementations for interacting with the pgvecter API.
This commit is contained in:
president
2026-02-07 08:38:06 +09:00
parent ae187d7338
commit 5d8dc0f2c9
9 changed files with 1171 additions and 0 deletions

View File

@@ -394,6 +394,19 @@ curl -s http://127.0.0.1:8092/health
1. `X-API-KEY` を必ず送る
2. `permissions.users` はサーバ側で強制注入される前提
### エンドポイント一覧(他サービス向け)
- `GET /health`: ヘルスチェック(認証不要)
- `POST /db/query`: SELECT-only の読み取りクエリ(`X-API-KEY` 必須)
- `POST /kb/upsert`: 1ドキュメントを upsert`X-API-KEY` 必須、`id` は UUID
- `POST /kb/search`: ベクトル検索(`X-API-KEY` 必須)
- `GET /collections`: コレクション一覧(`X-API-KEY` 必須)
- `GET /admin/collections`: 管理者向けコレクション一覧(`X-ADMIN-API-KEY` 必須)
- `GET /admin/api-keys`: 管理APIキー一覧`X-ADMIN-API-KEY` 必須)
- `POST /admin/api-keys`: 管理APIキー作成`X-ADMIN-API-KEY` 必須)
- `PATCH /admin/api-keys/{id}`: 管理APIキー更新`X-ADMIN-API-KEY` 必須)
- `POST /admin/api-keys/{id}/revoke`: 管理APIキー失効`X-ADMIN-API-KEY` 必須)
### 例: /kb/search
```bash

25
cmd/mcp-server/config.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"errors"
"os"
)
type Config struct {
BaseURL string
APIKey string
}
func LoadConfig() (Config, error) {
cfg := Config{
BaseURL: os.Getenv("PGVECTER_BASE_URL"),
APIKey: os.Getenv("PGVECTER_API_KEY"),
}
if cfg.BaseURL == "" {
return cfg, errors.New("PGVECTER_BASE_URL is required")
}
if cfg.APIKey == "" {
return cfg, errors.New("PGVECTER_API_KEY is required")
}
return cfg, nil
}

23
cmd/mcp-server/main.go Normal file
View File

@@ -0,0 +1,23 @@
package main
import (
"context"
"log"
"pgvecterapi/internal/mcp"
"pgvecterapi/internal/pgvecter"
)
func main() {
cfg, err := LoadConfig()
if err != nil {
log.Fatal(err)
}
client := pgvecter.NewClient(cfg.BaseURL, cfg.APIKey)
tools := mcp.NewToolSet(client)
if err := runMCPServer(context.Background(), tools); err != nil {
log.Fatal(err)
}
}

408
cmd/mcp-server/mcp.go Normal file
View File

@@ -0,0 +1,408 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"pgvecterapi/internal/mcp"
)
const protocolVersion = "2025-03-26"
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
type initializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities,omitempty"`
ClientInfo map[string]any `json:"clientInfo,omitempty"`
}
type initializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ServerInfo map[string]any `json:"serverInfo"`
}
type toolsListResult struct {
Tools []toolDef `json:"tools"`
NextCursor string `json:"nextCursor,omitempty"`
}
type toolDef struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
type toolsCallParams struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
type toolsCallResult struct {
Content []contentItem `json:"content"`
IsError bool `json:"isError"`
}
type contentItem struct {
Type string `json:"type"`
Text string `json:"text"`
}
func runMCPServer(ctx context.Context, tools *mcp.ToolSet) error {
scanner := bufio.NewScanner(os.Stdin)
buf := make([]byte, 0, 1024*1024)
scanner.Buffer(buf, 10*1024*1024)
writer := bufio.NewWriter(os.Stdout)
defer writer.Flush()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var req rpcRequest
if err := json.Unmarshal([]byte(line), &req); err != nil {
_ = writeRPCError(writer, nil, -32700, "parse error", err.Error())
continue
}
if req.Method == "" {
_ = writeRPCError(writer, req.ID, -32600, "invalid request", "missing method")
continue
}
// Notifications have no id
if len(req.ID) == 0 {
if req.Method == "initialized" {
continue
}
// Ignore unknown notifications
continue
}
switch req.Method {
case "initialize":
res := initializeResult{
ProtocolVersion: protocolVersion,
Capabilities: map[string]any{
"tools": map[string]any{"listChanged": false},
},
ServerInfo: map[string]any{
"name": "pgvecter-mcp",
"version": "0.1.0",
},
}
_ = writeRPCResult(writer, req.ID, res)
case "tools/list":
res := toolsListResult{Tools: toolList()}
_ = writeRPCResult(writer, req.ID, res)
case "tools/call":
out, err := handleToolCall(ctx, tools, req.Params)
if err != nil {
_ = writeRPCResult(writer, req.ID, toolsCallResult{
Content: []contentItem{{Type: "text", Text: err.Error()}},
IsError: true,
})
continue
}
_ = writeRPCResult(writer, req.ID, out)
default:
_ = writeRPCError(writer, req.ID, -32601, "method not found", req.Method)
}
}
if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
func handleToolCall(ctx context.Context, tools *mcp.ToolSet, raw json.RawMessage) (toolsCallResult, error) {
var params toolsCallParams
if err := json.Unmarshal(raw, &params); err != nil {
return toolsCallResult{}, fmt.Errorf("invalid params: %w", err)
}
if params.Name == "" {
return toolsCallResult{}, errors.New("tool name is required")
}
marshal := func(v any) ([]byte, error) {
return json.Marshal(v)
}
unmarshal := func(b []byte, v any) error {
return json.Unmarshal(b, v)
}
switch params.Name {
case "dev_log_upsert":
var in mcp.DevLogUpsertInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.DevLogUpsert(ctx, in)
return formatResult(res, err)
case "dev_log_search":
var in mcp.DevLogSearchInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.DevLogSearch(ctx, in)
return formatResult(res, err)
case "trade_event_upsert":
var in mcp.TradeEventUpsertInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.TradeEventUpsert(ctx, in)
return formatResult(res, err)
case "trade_event_search":
var in mcp.TradeEventSearchInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.TradeEventSearch(ctx, in)
return formatResult(res, err)
case "doc_upsert":
var in mcp.DocUpsertInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.DocUpsert(ctx, in)
return formatResult(res, err)
case "doc_search":
var in mcp.DocSearchInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.DocSearch(ctx, in)
return formatResult(res, err)
case "security_upsert":
var in mcp.SecurityUpsertInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.SecurityUpsert(ctx, in)
return formatResult(res, err)
case "security_search":
var in mcp.SecuritySearchInput
b, _ := marshal(params.Arguments)
if err := unmarshal(b, &in); err != nil {
return toolsCallResult{}, err
}
res, err := tools.SecuritySearch(ctx, in)
return formatResult(res, err)
default:
return toolsCallResult{}, fmt.Errorf("unknown tool: %s", params.Name)
}
}
func formatResult(res any, err error) (toolsCallResult, error) {
if err != nil {
return toolsCallResult{Content: []contentItem{{Type: "text", Text: err.Error()}}, IsError: true}, nil
}
b, _ := json.MarshalIndent(res, "", " ")
return toolsCallResult{Content: []contentItem{{Type: "text", Text: string(b)}}, IsError: false}, nil
}
func writeRPCResult(w *bufio.Writer, id json.RawMessage, result any) error {
resp := rpcResponse{JSONRPC: "2.0", ID: id, Result: result}
b, err := json.Marshal(resp)
if err != nil {
return err
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
return w.Flush()
}
func writeRPCError(w *bufio.Writer, id json.RawMessage, code int, msg string, data any) error {
resp := rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg, Data: data}}
b, err := json.Marshal(resp)
if err != nil {
return err
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
return w.Flush()
}
func toolList() []toolDef {
return []toolDef{
{
Name: "dev_log_upsert",
Title: "Dev Log Upsert",
Description: "Upsert a dev_log entry (title/body/metadata)",
InputSchema: schemaObject(map[string]any{
"id": schemaString("UUID"),
"title": schemaString("Title"),
"body": schemaString("Body"),
"ts": schemaString("ISO8601 timestamp"),
"tags": schemaArray(schemaString("Tag")),
"project": schemaString("Project name"),
"source": schemaString("Source (manual/git/cursor/terminal)"),
"author": schemaString("Author"),
"visibility": schemaString("public/private/confidential"),
"content_hash": schemaString("sha256:<hex>"),
}, []string{"id", "title", "body", "ts"}),
},
{
Name: "dev_log_search",
Title: "Dev Log Search",
Description: "Search dev_log entries",
InputSchema: schemaObject(map[string]any{
"query": schemaString("Query"),
"top_k": schemaNumber("Top K (default 5)"),
"filter": map[string]any{
"type": "object",
"description": "Optional metadata filter (MCP filter DSL)",
},
}, []string{"query"}),
},
{
Name: "trade_event_upsert",
Title: "Trade Event Upsert",
Description: "Upsert a trade_event entry",
InputSchema: schemaObject(map[string]any{
"id": schemaString("UUID"),
"event_date": schemaString("YYYY-MM-DD"),
"partner_name": schemaString("Partner name"),
"event_type": schemaString("見積/受注/請求/入金/支払/発注"),
"amount": schemaNumber("Amount"),
"currency": schemaString("Currency (default JPY)"),
"summary": schemaString("Short summary"),
"detail": map[string]any{"type": "object", "description": "Detail JSON"},
"doc_path": schemaString("Document path"),
"tags": schemaArray(schemaString("Tag")),
"direction": schemaString("in/out"),
"amount_signed": schemaNumber("Signed amount"),
"counterparty_id": schemaString("Optional company id"),
}, []string{"id", "event_date", "partner_name", "event_type", "summary"}),
},
{
Name: "trade_event_search",
Title: "Trade Event Search",
Description: "Search trade_event entries",
InputSchema: schemaObject(map[string]any{
"query": schemaString("Query"),
"top_k": schemaNumber("Top K (default 5)"),
"filter": map[string]any{
"type": "object",
"description": "Optional metadata filter (MCP filter DSL)",
},
}, []string{"query"}),
},
{
Name: "doc_upsert",
Title: "Doc Upsert",
Description: "Upsert a doc entry",
InputSchema: schemaObject(map[string]any{
"id": schemaString("UUID"),
"title": schemaString("Title"),
"ts": schemaString("ISO8601 timestamp"),
"tags": schemaArray(schemaString("Tag")),
"source": schemaString("Source (pdf_summary/manual)"),
"doc_path": schemaString("Document path"),
"body": schemaString("Body"),
}, []string{"id", "title", "body"}),
},
{
Name: "doc_search",
Title: "Doc Search",
Description: "Search doc entries",
InputSchema: schemaObject(map[string]any{
"query": schemaString("Query"),
"top_k": schemaNumber("Top K (default 5)"),
"filter": map[string]any{
"type": "object",
"description": "Optional metadata filter (MCP filter DSL)",
},
}, []string{"query"}),
},
{
Name: "security_upsert",
Title: "Security Upsert",
Description: "Upsert a security entry",
InputSchema: schemaObject(map[string]any{
"id": schemaString("UUID"),
"ts": schemaString("ISO8601 timestamp"),
"tags": schemaArray(schemaString("Tag")),
"source": schemaString("Source (fail2ban/cerberus)"),
"host": schemaString("Host name"),
"severity": schemaString("info/warn/error"),
"body": schemaString("Message"),
}, []string{"id", "body", "ts"}),
},
{
Name: "security_search",
Title: "Security Search",
Description: "Search security entries",
InputSchema: schemaObject(map[string]any{
"query": schemaString("Query"),
"top_k": schemaNumber("Top K (default 5)"),
"filter": map[string]any{
"type": "object",
"description": "Optional metadata filter (MCP filter DSL)",
},
}, []string{"query"}),
},
}
}
func schemaObject(props map[string]any, required []string) map[string]any {
m := map[string]any{
"type": "object",
"properties": props,
}
if len(required) > 0 {
m["required"] = required
}
return m
}
func schemaString(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
func schemaNumber(desc string) map[string]any {
return map[string]any{"type": "number", "description": desc}
}
func schemaArray(items map[string]any) map[string]any {
return map[string]any{"type": "array", "items": items}
}

337
docs/mcp_dev_memo.md Normal file
View File

@@ -0,0 +1,337 @@
# MCP 開発メモ (pgvecterAPI)
## 目的
- MCP サーバー (Go) から pgvecterAPI を使って「開発日記」「雑談日記」「取引情報」の読み書き・検索を行う。
- 使用するコレクション: `dev_diary`, `chitchat_diary`, `trade_event`
## 前提
- pgvecterAPI が稼働中 (例: https://labo.sunamura-llc.com/pgvecter/)
- API キーを発行済み (`X-API-KEY: pgv_...`)
- 管理用は `X-ADMIN-API-KEY` を別途保持
## コレクション設計
- `dev_diary`: 開発日記 (Codex 接続、読み書き・検索)
- `chitchat_diary`: カスタム GPT の雑談日記 (読み書き・検索)
- `trade_event`: 自前 AI の取引情報 (読み書き・検索)
- `doc`: PDF 要約など一般文書
- `security`: Cerberus / Fail2Ban / 監視ログ
## メタデータ方針
共通キー案:
- `source`: `codex` / `custom_gpt` / `internal_ai`
- `author`: ユーザー名 or サービス名
- `topic`: 任意 (例: `infra`, `api`, `trading`)
- `ts`: ISO8601 (`2026-02-06T20:52:31+09:00`)
- `tags`: 文字列配列
例:
```json
{
"source": "codex",
"author": "hideyuki",
"topic": "deploy",
"ts": "2026-02-06T21:30:00+09:00",
"tags": ["pgvecter", "mcp"]
}
```
## API 仕様 (最低限)
- `POST /kb/upsert`:
- `id` は UUID 必須
- `collection``dev_diary` / `chitchat_diary` / `trade_event`
- `content` が本文
- `metadata` に上記メタデータ
- `embedding` 省略時は llama.cpp で自動生成
- `POST /kb/search`:
- `collection` を指定
- `query` で検索
- `top_k` を指定
- `filter` で metadata フィルタ可能
## curl 例 (dev_diary)
### upsert
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/upsert \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"id":"00000000-0000-0000-0000-000000000001",
"collection":"dev_diary",
"content":"MCP の設計を整理した。",
"metadata":{
"source":"codex",
"author":"hideyuki",
"topic":"mcp",
"ts":"2026-02-06T21:30:00+09:00",
"tags":["design","memo"]
}
}'
```
### search
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/search \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"collection":"dev_diary",
"query":"MCP 設計のメモ",
"top_k":5
}'
```
## MCP サーバー (Go) 実装方針
- ツール設計例
- `diary_upsert(collection, text, metadata)`
- `diary_search(collection, query, top_k, filter)`
- UUID 生成: `github.com/google/uuid` など
- HTTP クライアント: 標準 `net/http`
- `X-API-KEY` は MCP サーバーの環境変数から注入
## 追加メモ
- 1リクエスト1ドキュメント upsert なので、複数件を扱う場合は MCP 側でループ
- `permissions.users` はサーバー側で強制注入される前提
- 収集対象別の collection により検索が明確になる
## dev_log スキーマとマッピング
**想定フィールド**
- `id` UUID
- `ts` timestamp
- `title` text
- `body` text
- `tags` text[]
- `project` text
- `source` text
- `author` text
- `visibility` text (`public` / `private` / `confidential`)
- `content_hash` text (SHA256)
- `embedding` vector(1024) (bge-m3 固定)
**pgvecterAPI へのマッピング**
- `id` -> `id` (UUID 必須)
- `body` -> `content`
- `title` -> `metadata.title` (必要なら content 先頭に埋め込み)
- `ts` -> `metadata.ts` (ISO8601)
- `tags` -> `metadata.tags`
- `project` -> `metadata.project`
- `source` -> `metadata.source`
- `author` -> `metadata.author`
- `visibility` -> `metadata.visibility`
- `content_hash` -> `metadata.content_hash`
- `embedding` -> 省略して自動生成 (bge-m3 固定運用)
**コレクション名**
- `dev_log`
**content_hash 生成ルール(採用)**
- 対象: `title``body`
- 連結: `title + \"\\n\" + body`
- 正規化: 先頭/末尾の空白を trim、改行を LF に統一
- ハッシュ: SHA-256
- 保存形式: `sha256:<hex>`
## dev_log テンプレ
### upsert (dev_log)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/upsert \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"id":"00000000-0000-0000-0000-000000000001",
"collection":"dev_log",
"content":"【タイトル】\n本文をここに書く。",
"metadata":{
"title":"タイトル",
"ts":"2026-02-06T21:30:00+09:00",
"tags":["llama.cpp","pgvector"],
"project":"pgvecterAPI",
"source":"terminal",
"author":"hideyuki",
"visibility":"private",
"content_hash":"sha256:..."
}
}'
```
### search (dev_log)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/search \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"collection":"dev_log",
"query":"埋め込みと検索の設計",
"top_k":5,
"filter": {
"and":[
{"eq": {"metadata.project":"pgvecterAPI"}},
{"contains": {"metadata.tags":"pgvector"}}
]
}
}'
```
## trade_event スキーマと補足
**想定フィールド**
- `id` UUID
- `event_date` date
- `partner_name` text
- `event_type` text
- `amount` numeric
- `currency` text (default `JPY`)
- `summary` text
- `detail` jsonb
- `doc_path` text
- `tags` text[]
- `embedding` vector(1024) (summary + detail をまとめて埋める)
**補足フィールド (採用)**
- `direction` text (`in` / `out`)
- `amount_signed` numeric (入金=プラス / 支払=マイナス)
- `counterparty_id` text (将来の company_master 連携用, nullable)
**pgvecterAPI へのマッピング**
- `id` -> `id` (UUID 必須)
- `summary` + `detail` -> `content` (summary + detail の主要項目をテキスト化)
- `event_date` -> `metadata.event_date` (YYYY-MM-DD)
- `partner_name` -> `metadata.partner_name`
- `event_type` -> `metadata.event_type`
- `amount` -> `metadata.amount`
- `currency` -> `metadata.currency`
- `doc_path` -> `metadata.doc_path`
- `tags` -> `metadata.tags`
- `detail` -> `metadata.detail` (JSON をそのまま格納)
- `direction` -> `metadata.direction`
- `amount_signed` -> `metadata.amount_signed`
- `counterparty_id` -> `metadata.counterparty_id`
- `embedding` -> 省略して自動生成 (bge-m3 固定運用)
**コレクション名**
- `trade_event`
## trade_event テンプレ
### upsert (trade_event)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/upsert \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"id":"00000000-0000-0000-0000-000000000002",
"collection":"trade_event",
"content":"【見積】A社 2026-01-15\n50万円の見積。担当:山田。参照: Q-2026-001。",
"metadata":{
"event_date":"2026-01-15",
"partner_name":"A社",
"event_type":"見積",
"amount":500000,
"currency":"JPY",
"summary":"A社向け見積 50万円",
"detail":{
"reference":"Q-2026-001",
"owner":"山田",
"notes":"条件: 2ヶ月以内の発注"
},
"doc_path":"/nextcloud/trade/A社/Q-2026-001.pdf",
"tags":["見積","A社"],
"direction":"in",
"amount_signed":500000,
"counterparty_id":null
}
}'
```
### search (trade_event)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/search \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"collection":"trade_event",
"query":"A社 去年の揉めた件",
"top_k":5,
"filter": {
"and":[
{"eq": {"metadata.partner_name":"A社"}},
{"contains": {"metadata.tags":"揉めた"}}
]
}
}'
```
## doc テンプレ
### upsert (doc)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/upsert \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"id":"00000000-0000-0000-0000-000000000003",
"collection":"doc",
"content":"【契約書要約】A社\n契約期間は2026年4月〜2027年3月。解約は30日前通知。",
"metadata":{
"title":"A社 取引基本契約書",
"ts":"2026-02-06T22:00:00+09:00",
"tags":["契約","A社"],
"source":"pdf_summary",
"doc_path":"/nextcloud/docs/contract_A_2026.pdf"
}
}'
```
### search (doc)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/search \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"collection":"doc",
"query":"A社 契約 更新条件",
"top_k":5,
"filter": {
"contains": {"metadata.tags":"契約"}
}
}'
```
## security テンプレ
### upsert (security)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/upsert \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"id":"00000000-0000-0000-0000-000000000004",
"collection":"security",
"content":"Fail2Ban: 203.0.113.10 を 10分 ban。理由: SSH brute force。",
"metadata":{
"ts":"2026-02-06T22:05:00+09:00",
"tags":["fail2ban","ssh"],
"source":"fail2ban",
"host":"x85-131-243-202",
"severity":"warn"
}
}'
```
### search (security)
```bash
curl -s -X POST https://labo.sunamura-llc.com/pgvecter/kb/search \
-H "X-API-KEY: pgv_..." \
-H "Content-Type: application/json" \
-d '{
"collection":"security",
"query":"SSH brute force の最近の事例",
"top_k":5,
"filter": {
"contains": {"metadata.tags":"ssh"}
}
}'
```

219
internal/mcp/tools.go Normal file
View File

@@ -0,0 +1,219 @@
package mcp
import (
"context"
"time"
"pgvecterapi/internal/pgvecter"
)
type ToolSet struct {
Client *pgvecter.Client
}
func NewToolSet(client *pgvecter.Client) *ToolSet {
return &ToolSet{Client: client}
}
type DevLogUpsertInput struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
TS string `json:"ts"`
Tags []string `json:"tags"`
Project string `json:"project"`
Source string `json:"source"`
Author string `json:"author"`
Visibility string `json:"visibility"`
ContentHash string `json:"content_hash"`
}
type DevLogSearchInput struct {
Query string `json:"query"`
TopK int `json:"top_k"`
Filter map[string]any `json:"filter"`
}
type TradeEventUpsertInput struct {
ID string `json:"id"`
EventDate string `json:"event_date"`
PartnerName string `json:"partner_name"`
EventType string `json:"event_type"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Summary string `json:"summary"`
Detail map[string]any `json:"detail"`
DocPath string `json:"doc_path"`
Tags []string `json:"tags"`
Direction string `json:"direction"`
AmountSigned float64 `json:"amount_signed"`
CounterpartyID string `json:"counterparty_id"`
}
type TradeEventSearchInput struct {
Query string `json:"query"`
TopK int `json:"top_k"`
Filter map[string]any `json:"filter"`
}
type DocUpsertInput struct {
ID string `json:"id"`
Title string `json:"title"`
TS string `json:"ts"`
Tags []string `json:"tags"`
Source string `json:"source"`
DocPath string `json:"doc_path"`
Body string `json:"body"`
}
type DocSearchInput struct {
Query string `json:"query"`
TopK int `json:"top_k"`
Filter map[string]any `json:"filter"`
}
type SecurityUpsertInput struct {
ID string `json:"id"`
TS string `json:"ts"`
Tags []string `json:"tags"`
Source string `json:"source"`
Host string `json:"host"`
Severity string `json:"severity"`
Body string `json:"body"`
}
type SecuritySearchInput struct {
Query string `json:"query"`
TopK int `json:"top_k"`
Filter map[string]any `json:"filter"`
}
func (t *ToolSet) DevLogUpsert(ctx context.Context, in DevLogUpsertInput) (*pgvecter.UpsertResponse, error) {
content := "【" + in.Title + "】\n" + in.Body
metadata := map[string]any{
"title": in.Title,
"ts": in.TS,
"tags": in.Tags,
"project": in.Project,
"source": in.Source,
"author": in.Author,
"visibility": in.Visibility,
"content_hash": in.ContentHash,
}
return t.Client.Upsert(ctx, pgvecter.UpsertRequest{
ID: in.ID,
Collection: "dev_log",
Content: content,
Metadata: metadata,
})
}
func (t *ToolSet) DevLogSearch(ctx context.Context, in DevLogSearchInput) (*pgvecter.SearchResponse, error) {
if in.TopK == 0 {
in.TopK = 5
}
return t.Client.Search(ctx, pgvecter.SearchRequest{
Query: in.Query,
TopK: in.TopK,
Collection: "dev_log",
Filter: in.Filter,
})
}
func (t *ToolSet) TradeEventUpsert(ctx context.Context, in TradeEventUpsertInput) (*pgvecter.UpsertResponse, error) {
content := "【" + in.EventType + "】" + in.PartnerName + " " + in.EventDate + "\n" + in.Summary
metadata := map[string]any{
"event_date": in.EventDate,
"partner_name": in.PartnerName,
"event_type": in.EventType,
"amount": in.Amount,
"currency": in.Currency,
"summary": in.Summary,
"detail": in.Detail,
"doc_path": in.DocPath,
"tags": in.Tags,
"direction": in.Direction,
"amount_signed": in.AmountSigned,
"counterparty_id": in.CounterpartyID,
}
return t.Client.Upsert(ctx, pgvecter.UpsertRequest{
ID: in.ID,
Collection: "trade_event",
Content: content,
Metadata: metadata,
})
}
func (t *ToolSet) TradeEventSearch(ctx context.Context, in TradeEventSearchInput) (*pgvecter.SearchResponse, error) {
if in.TopK == 0 {
in.TopK = 5
}
return t.Client.Search(ctx, pgvecter.SearchRequest{
Query: in.Query,
TopK: in.TopK,
Collection: "trade_event",
Filter: in.Filter,
})
}
func (t *ToolSet) DocUpsert(ctx context.Context, in DocUpsertInput) (*pgvecter.UpsertResponse, error) {
content := "【" + in.Title + "】\n" + in.Body
metadata := map[string]any{
"title": in.Title,
"ts": in.TS,
"tags": in.Tags,
"source": in.Source,
"doc_path": in.DocPath,
}
return t.Client.Upsert(ctx, pgvecter.UpsertRequest{
ID: in.ID,
Collection: "doc",
Content: content,
Metadata: metadata,
})
}
func (t *ToolSet) DocSearch(ctx context.Context, in DocSearchInput) (*pgvecter.SearchResponse, error) {
if in.TopK == 0 {
in.TopK = 5
}
return t.Client.Search(ctx, pgvecter.SearchRequest{
Query: in.Query,
TopK: in.TopK,
Collection: "doc",
Filter: in.Filter,
})
}
func (t *ToolSet) SecurityUpsert(ctx context.Context, in SecurityUpsertInput) (*pgvecter.UpsertResponse, error) {
content := in.Body
metadata := map[string]any{
"ts": in.TS,
"tags": in.Tags,
"source": in.Source,
"host": in.Host,
"severity": in.Severity,
}
return t.Client.Upsert(ctx, pgvecter.UpsertRequest{
ID: in.ID,
Collection: "security",
Content: content,
Metadata: metadata,
})
}
func (t *ToolSet) SecuritySearch(ctx context.Context, in SecuritySearchInput) (*pgvecter.SearchResponse, error) {
if in.TopK == 0 {
in.TopK = 5
}
return t.Client.Search(ctx, pgvecter.SearchRequest{
Query: in.Query,
TopK: in.TopK,
Collection: "security",
Filter: in.Filter,
})
}
func NowISO() string {
return time.Now().Format(time.RFC3339)
}

View File

@@ -0,0 +1,94 @@
package pgvecter
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type Client struct {
BaseURL string
APIKey string
HTTP *http.Client
}
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
HTTP: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *Client) Upsert(ctx context.Context, req UpsertRequest) (*UpsertResponse, error) {
if req.ID == "" {
return nil, errors.New("id is required")
}
if req.Content == "" {
return nil, errors.New("content is required")
}
var resp UpsertResponse
if err := c.postJSON(ctx, "/kb/upsert", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func (c *Client) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) {
if req.Query == "" && len(req.QueryEmbedding) == 0 {
return nil, errors.New("query or query_embedding is required")
}
var resp SearchResponse
if err := c.postJSON(ctx, "/kb/search", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func (c *Client) postJSON(ctx context.Context, path string, body any, out any) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
url := c.BaseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.APIKey != "" {
req.Header.Set("X-API-KEY", c.APIKey)
}
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
var er ErrorResponse
if json.Unmarshal(data, &er) == nil && er.Error != "" {
return fmt.Errorf("pgvecter error: %s (%s)", er.Error, er.Code)
}
return fmt.Errorf("pgvecter http %d: %s", res.StatusCode, strings.TrimSpace(string(data)))
}
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}

View File

@@ -0,0 +1,52 @@
package pgvecter
type UpsertRequest struct {
ID string `json:"id"`
DocID string `json:"doc_id,omitempty"`
ChunkIndex int `json:"chunk_index,omitempty"`
Collection string `json:"collection,omitempty"`
AutoChunk bool `json:"auto_chunk,omitempty"`
ChunkSize int `json:"chunk_size,omitempty"`
ChunkOver int `json:"chunk_overlap,omitempty"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
Embedding []float64 `json:"embedding,omitempty"`
}
type UpsertResponse struct {
ID string `json:"id"`
DocID string `json:"doc_id"`
Updated bool `json:"updated"`
Chunks []UpsertChunk `json:"chunks,omitempty"`
}
type UpsertChunk struct {
ID string `json:"id"`
ChunkIndex int `json:"chunk_index"`
}
type SearchRequest struct {
Query string `json:"query"`
QueryEmbedding []float64 `json:"query_embedding,omitempty"`
TopK int `json:"top_k,omitempty"`
Collection string `json:"collection,omitempty"`
Collections []string `json:"collections,omitempty"`
Filter map[string]any `json:"filter,omitempty"`
}
type SearchResponse struct {
Results []SearchResult `json:"results"`
}
type SearchResult struct {
ID string `json:"id"`
Collection string `json:"collection"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
Score float64 `json:"score"`
}
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code"`
}

BIN
pgvecterapi Executable file

Binary file not shown.