#
tokens: 14120/50000 9/9 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .dockerignore
├── .github
│   └── workflows
│       ├── ci.yaml
│       └── docker-publish.yml
├── .gitignore
├── .goreleaser.yml
├── Dockerfile
├── go.mod
├── go.sum
├── LICENSE
├── main.go
├── README.ja.md
├── README.md
├── smithery.yaml
└── tools_list.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
mcp-server-kintone
mcp-server-kintone.exe

```

--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------

```
Dockerfile
mcp-server-kintone
mcp-server-kintone.exe

```

--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------

```yaml
before:
  hooks:
    - go mod tidy

builds:
  - id: with-upx
    env:
      - CGO_ENABLED=0
    goos:
      - linux
    flags:
      - -trimpath
    ldflags:
      - '-s -w'
      - '-X main.Version={{ .Version }}'
      - '-X main.Commit={{ .ShortCommit }}'
    hooks:
      post: 'upx-ucl --lzma {{ .Path }}'
  - id: without-upx
    env:
      - CGO_ENABLED=0
    goos:
      - windows
      - darwin
    flags:
      - -trimpath
    ldflags:
      - '-s -w'
      - '-X main.Version={{ .Version }}'
      - '-X main.Commit={{ .ShortCommit }}'

archives:
  - format: tar.gz
    name_template: >-
      {{- .ProjectName -}}
      _
      {{- .Version -}}
      _
      {{- .Os -}}
      _
      {{- if eq .Arch "386" -}}
        i386
      {{- else if eq .Arch "amd64" -}}
        x86_64
      {{- else -}}
        {{- .Arch -}}
      {{- end -}}
    format_overrides:
      - goos: windows
        format: zip
    files: [none*]

changelog:
  filters:
    exclude:
      - '^chore'
      - '^docs'
      - '^test'

```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
FROM golang:latest AS builder

WORKDIR /app

COPY go.mod ./

RUN go mod download

COPY . .

RUN CGO_ENABLED=0 go build -o mcp-server-kintone


ARG BASE_IMAGE
FROM ${BASE_IMAGE:-alpine:latest}

COPY --from=builder /app/mcp-server-kintone /

ENTRYPOINT ["/mcp-server-kintone"]

```

--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------

```yaml
name: CI

on:
  push:

jobs:
  test:
    name: Test
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - uses: actions/setup-go@v3
        with:
          go-version: 1.23.x
      - uses: actions/checkout@v3
      - name: Test
        run: go test -race ./...

  analyze:
    name: CodeQL
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: github/codeql-action/init@v2
        with:
          languages: go
      - uses: github/codeql-action/analyze@v2

  release:
    name: Release
    needs: [test, analyze]
    if: "contains(github.ref, 'tags/v')"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/setup-go@v3
        with:
          go-version: 1.23.x
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - name: Install upx-ucl
        run: sudo apt install upx-ucl -y
      - uses: goreleaser/goreleaser-action@v2
        with:
          version: latest
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml

startCommand:
  type: stdio
  configSchema:
    type: object
    anyOf:
      - required: [url, username, password]
      - required: [url, token]
    properties:
      url:
        type: string
        description: The URL of the kintone domain. e.g. https://example.kintone.com
      username:
        type: string
        description: The username for kintone authentication. This option and the password option are required if the token option is not provided.
      password:
        type: string
        description: The password for kintone authentication. This option and the username option are required if the token option is not provided.
      token:
        type: string
        description: The API token for kintone authentication. This option is required if the username and password options are not provided.
      allowApps:
        type: string
        description: A comma-separated list of app IDs to allow. If not provided, all apps are allowed.
      denyApps:
        type: string
        description: A comma-separated list of app IDs to deny. If not provided, no apps are denied.

  commandFunction: |
    config => ({
      command: './mcp-server-kintone',
      env: {
        KINTONE_BASE_URL: config.url,
        KINTONE_USERNAME: config.username,
        KINTONE_PASSWORD: config.password,
        KINTONE_API_TOKEN: config.token,
        KINTONE_ALLOW_APPS: config.allowApps,
        KINTONE_DENY_APPS: config.denyApps,
      },
    })

```

--------------------------------------------------------------------------------
/.github/workflows/docker-publish.yml:
--------------------------------------------------------------------------------

```yaml
name: Docker

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

on:
  schedule:
    - cron: '18 11 * * *'
  push:
    branches: [ "main" ]
    # Publish semver tags as releases.
    tags: [ 'v*.*.*' ]
  pull_request:
    branches: [ "main" ]

env:
  # Use docker.io for Docker Hub if empty
  REGISTRY: ghcr.io
  # github.repository as <account>/<repo>
  IMAGE_NAME: ${{ github.repository }}


jobs:
  build:

    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      # This is used to complete the identity challenge
      # with sigstore/fulcio when running outside of PRs.
      id-token: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      # Install the cosign tool except on PR
      # https://github.com/sigstore/cosign-installer
      - name: Install cosign
        if: github.event_name != 'pull_request'
        uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
        with:
          cosign-release: 'v2.2.4'

      # Set up BuildKit Docker container builder to be able to build
      # multi-platform images and export cache
      # https://github.com/docker/setup-buildx-action
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0

      # Login against a Docker registry except on PR
      # https://github.com/docker/login-action
      - name: Log into registry ${{ env.REGISTRY }}
        if: github.event_name != 'pull_request'
        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Extract metadata (tags, labels) for Docker
      # https://github.com/docker/metadata-action
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      # Build and push Docker image with Buildx (don't push on PR)
      # https://github.com/docker/build-push-action
      - name: Build and push Docker image
        id: build-and-push
        uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            BASE_IMAGE=scratch

      # Sign the resulting Docker image digest except on PRs.
      # This will only write to the public Rekor transparency log when the Docker
      # repository is public to avoid leaking data.  If you would like to publish
      # transparency data even for private images, pass --force to cosign below.
      # https://github.com/sigstore/cosign
      - name: Sign the published Docker image
        if: ${{ github.event_name != 'pull_request' }}
        env:
          # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
          TAGS: ${{ steps.meta.outputs.tags }}
          DIGEST: ${{ steps.build-and-push.outputs.digest }}
        # This step uses the identity token to provision an ephemeral certificate
        # against the sigstore community Fulcio instance.
        run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

```

--------------------------------------------------------------------------------
/tools_list.json:
--------------------------------------------------------------------------------

```json
{{ define "kintoneRecordProperties" }}
{
  "properties": {
    "value": {
      "anyOf": [
        {
          "description": "Usual values for text, number, etc.",
          "type": "string"
        },
        {
          "description": "Values for checkbox.",
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "description": "Values for file attachment.",
          "items": {
            "properties": {
              "contentType": {
                "description": "The content type of the file.",
                "type": "string"
              },
              "fileKey": {
                "description": "The file key. You can get the file key to upload a file by using 'uploadAttachmentFile' tool. The file can donwload by using 'downloadAttachmentFile' tool.",
                "type": "string"
              },
              "name": {
                "description": "The file name.",
                "type": "string"
              }
            },
            "type": "object"
          },
          "type": "array"
        },
        {
          "description": "Values for table.",
          "properties": {
            "value": {
              "items": {
                "properties": {
                  "value": {
                    "additionalProperties": {
                      "properties": {
                        "value": {}
                      },
                      "required": [
                        "value"
                      ],
                      "type": "object"
                    },
                    "type": "object"
                  }
                },
                "required": [
                  "value"
                ],
                "type": "object"
              },
              "type": "array"
            }
          },
          "required": [
            "value"
          ],
          "type": "object"
        }
      ]
    }
  },
  "required": [
    "value"
  ],
  "type": "object"
}
{{ end }}

{
  "tools": [
    {
      "name": "listApps",
      "description": "List all applications made on kintone. Response includes the app ID, name, and description.",
      "inputSchema": {
        "properties": {
          "offset": {
            "description": "The offset of apps to read. Default is 0.",
            "type": "number"
          },
          "limit": {
            "description": "The maximum number of apps to read. Default is 100, maximum is 100. The result might be different from the limit because of the permission.",
            "type": "number"
          },
          "name": {
            "description": "The name or a part of name of the apps to search. Highly recommended to use this parameter to find the app you want to use.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "annotations": {
        "title": "List kintone apps",
        "readOnlyHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "readAppInfo",
      "description": "Get information about the specified app. Response includes the app ID, name, description, and schema.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to get information from.",
            "type": "string"
          }
        },
        "required": [
          "appID"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Read kintone app information",
        "readOnlyHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "createRecord",
      "description": "Create a new record in the specified app. Before use this tool, you better to know the schema of the app by using 'readAppInfo' tool.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to create a record in.",
            "type": "string"
          },
          "record": {
            "additionalProperties": {{ template "kintoneRecordProperties" }},
            "description": "The record data to create. Record data format is the same as kintone's record data format. For example, {\"field1\": {\"value\": \"value1\"}, \"field2\": {\"value\": \"value2\"}, \"field3\": {\"value\": \"value3\"}}.",
            "type": "object"
          }
        },
        "required": [
          "appID",
          "record"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Create a kintone record",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      }
    },
    {
      "name": "readRecords",
      "description": "Read records from the specified app. Response includes the record ID and record data. Before search records using this tool, you better to know the schema of the app by using 'readAppInfo' tool.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to read records from.",
            "type": "string"
          },
          "fields": {
            "description": "The field codes to include in the response. Default is all fields.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "limit": {
            "description": "The maximum number of records to read. Default is 10, maximum is 500.",
            "type": "number"
          },
          "offset": {
            "description": "The offset of records to read. Default is 0, maximum is 10,000.",
            "type": "number"
          },
          "query": {
            "description": "The query to filter records. Query format is the same as kintone's query format. For example, 'field1 = \"value1\" and (field2 like \"value2\"' or field3 not in (\"value3.1\",\"value3.2\")) and date > \"2006-01-02\"'.",
            "type": "string"
          }
        },
        "required": [
          "appID"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Read kintone records",
        "readOnlyHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "updateRecord",
      "description": "Update the specified record in the specified app. Before use this tool, you better to know the schema of the app by using 'readAppInfo' tool and check which record to update by using 'readRecords' tool.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to update a record in.",
            "type": "string"
          },
          "record": {
            "additionalProperties": {{ template "kintoneRecordProperties" }},
            "description": "The record data to update. Record data format is the same as kintone's record data format. For example, {\"field1\": {\"value\": \"value1\"}, \"field2\": {\"value\": \"value2\"}, \"field3\": {\"value\": \"value3\"}}. Omits the field that you don't want to update.",
            "type": "object"
          },
          "recordID": {
            "description": "The record ID to update.",
            "type": "string"
          }
        },
        "required": [
          "appID",
          "recordID",
          "record"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Update a kintone record",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "deleteRecord",
      "description": "Delete the specified record in the specified app. Before use this tool, you should check which record to delete by using 'readRecords' tool. This operation is unrecoverable, so make sure that the user really want to delete the record.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to delete a record from.",
            "type": "string"
          },
          "recordID": {
            "description": "The record ID to delete.",
            "type": "string"
          }
        },
        "required": [
          "appID",
          "recordID"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Delete a kintone records",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "downloadAttachmentFile",
      "description": "Download the specified attachment file. Before use this tool, you should check file key by using 'readRecords' tool.",
      "inputSchema": {
        "properties": {
          "fileKey": {
            "description": "The file key to download.",
            "type": "string"
          }
        },
        "required": [
          "fileKey"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Download a file from kintone",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      }
    },
    {
      "name": "uploadAttachmentFile",
      "description": "Upload a new attachment file to the specified app. The response includes a file key that you can use for creating or updating records.",
      "inputSchema": {
        "description": "The file to upload. You can specify the file by path or content.",
        "properties": {
          "path": {
            "description": "The path of the file to upload. Required if `content` is not specified.",
            "type": "string"
          },
          "content": {
            "description": "The content of the file to upload. Required if `path` is not specified.",
            "type": "string"
          },
          "name": {
            "description": "The file name for the `content`. This is only used when `content` is specified.",
            "type": "string"
          },
          "base64": {
            "description": "The `content` is base64 encoded or not. Default is false. This is only used when `content` is specified.",
            "type": "boolean"
          }
        },
        "type": "object"
      },
      "annotations": {
        "title": "Upload a file to kintone",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "readRecordComments",
      "description": "Read comments on the specified record in the specified app.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to read comments from.",
            "type": "string"
          },
          "limit": {
            "description": "The maximum number of comments to read. Default is 10, maximum is 10.",
            "type": "number"
          },
          "offset": {
            "description": "The offset of comments to read. Default is 0.",
            "type": "number"
          },
          "order": {
            "description": "The order of comments. Default is 'desc'.",
            "type": "string"
          },
          "recordID": {
            "description": "The record ID to read comments from.",
            "type": "string"
          }
        },
        "required": [
          "appID",
          "recordID"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Read kintone record's comments",
        "readOnlyHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "createRecordComment",
      "description": "Create a new comment on the specified record in the specified app.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to create a comment in.",
            "type": "string"
          },
          "comment": {
            "properties": {
              "mentions": {
                "description": "The mention targets of the comment. The target can be a user, a group, or a organization.",
                "items": {
                  "properties": {
                    "code": {
                      "description": "The code of the mention target. You can get the code by other records or comments.",
                      "type": "string"
                    },
                    "type": {
                      "description": "The type of the mention target. Default is 'USER'.",
                      "enum": [
                        "USER",
                        "GROUP",
                        "ORGANIZATION"
                      ],
                      "type": "string"
                    }
                  },
                  "required": [
                    "code"
                  ],
                  "type": "object"
                },
                "type": "array"
              },
              "text": {
                "description": "The text of the comment.",
                "type": "string"
              }
            },
            "required": [
              "text"
            ],
            "type": "object"
          },
          "recordID": {
            "description": "The record ID to create a comment on.",
            "type": "string"
          }
        },
        "required": [
          "appID",
          "recordID",
          "comment"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Post a comment to kintone record",
        "readOnlyHint": false,
        "destructiveHint": false,
        "idempotentHint": false,
        "openWorldHint": true
      }
    },
    {
      "name": "updateProcessManagementAssignee",
      "description": "Update the assignee of process management of the specified record in the specified app.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to update the assignee.",
            "type": "string"
          },
          "recordID": {
            "description": "The record ID to update the assignee.",
            "type": "string"
          },
          "assignees": {
            "description": "The assignee of the record.",
            "type": "array",
            "items": {
              "type": "string",
              "description": "The code of the assignee user."
            }
          }
        },
        "required": [
          "appID",
          "recordID",
          "assignees"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Update kintone record's assignee",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": true,
        "openWorldHint": true
      }
    },
    {
      "name": "executeProcessManagementAction",
      "description": "Execute the specified action of process management of the specified record in the specified app.",
      "inputSchema": {
        "properties": {
          "appID": {
            "description": "The app ID to execute the action.",
            "type": "string"
          },
          "recordID": {
            "description": "The record ID to execute the action.",
            "type": "string"
          },
          "action": {
            "description": "The action to execute.",
            "type": "string"
          },
          "assignee": {
            "description": "The next assignee of the record.",
            "type": "string"
          }
        },
        "required": [
          "appID",
          "recordID",
          "action"
        ],
        "type": "object"
      },
      "annotations": {
        "title": "Execute kintone record's process management action",
        "readOnlyHint": false,
        "destructiveHint": true,
        "idempotentHint": false,
        "openWorldHint": true
      }
    }
  ]
}
{{/* vim: set ft=json et ts=2 sw=2: */}}

```

--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------

```go
package main

import (
	"bytes"
	"context"
	_ "embed"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"text/template"

	"github.com/macrat/go-jsonrpc2"
)

var (
	Version = "UNKNOWN"
	Commit  = "HEAD"
)

type JsonMap map[string]any

type ServerInfo struct {
	Name    string `json:"name"`
	Version string `json:"version"`
}

type InitializeRequest struct {
	ProtocolVersion string `json:"protocolVersion"`
}

type InitializeResult struct {
	ProtocolVersion string     `json:"protocolVersion"`
	Capabilities    JsonMap    `json:"capabilities"`
	ServerInfo      ServerInfo `json:"serverInfo"`
	Instructions    string     `json:"instructions"`
}

type Content struct {
	Type     string `json:"type"`
	Text     string `json:"text,omitempty"`
	Data     string `json:"data,omitempty"`
	MimeType string `json:"mimeType,omitempty"`
}

func JSONContent(v any) ([]Content, error) {
	bs, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		return nil, err
	}
	return []Content{{Type: "text", Text: string(bs)}}, nil
}

type ToolInfo struct {
	Name        string  `json:"name"`
	Description string  `json:"description,omitempty"`
	InputSchema JsonMap `json:"inputSchema"`
}

type ToolsListResult struct {
	Tools []ToolInfo `json:"tools"`
}

type ToolsCallRequest struct {
	Name      string          `json:"name"`
	Arguments json.RawMessage `json:"arguments"`
}

type ToolsCallResult struct {
	Content []Content `json:"content"`
	IsError bool      `json:"isError"`
}

func UnmarshalParams[T any](data []byte, target *T) error {
	err := json.Unmarshal(data, target)
	if err != nil {
		return jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: fmt.Sprintf("Failed to parse parameters: %v", err),
		}
	}
	return nil
}

type ProcessManagement struct {
	Enable  bool               `json:"enable"`
	States  map[string]JsonMap `json:"states,omitempty"`
	Actions []JsonMap          `json:"actions,omitempty"`
}

type KintoneAppDetail struct {
	AppID             string            `json:"appID"`
	Name              string            `json:"name"`
	Description       string            `json:"description,omitempty"`
	Properties        JsonMap           `json:"properties,omitempty"`
	CreatedAt         string            `json:"createdAt"`
	ModifiedAt        string            `json:"modifiedAt"`
	ProcessManagement ProcessManagement `json:"processManagement,omitempty"`
}

type KintoneHandlers struct {
	URL   *url.URL
	Auth  string
	Token string
	Allow []string
	Deny  []string
}

func NewKintoneHandlersFromEnv() (*KintoneHandlers, error) {
	var handlers KintoneHandlers
	errs := []error{errors.New("Error:")}

	username := Getenv("KINTONE_USERNAME", "")
	password := Getenv("KINTONE_PASSWORD", "")
	tokens := Getenv("KINTONE_API_TOKEN", "")
	if (username == "" || password == "") && tokens == "" {
		errs = append(errs, errors.New("- Either KINTONE_USERNAME/KINTONE_PASSWORD or KINTONE_API_TOKEN must be provided"))
	}
	if username != "" && password != "" {
		handlers.Auth = base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", username, password))
	}
	handlers.Token = tokens

	baseURL := Getenv("KINTONE_BASE_URL", "")
	if baseURL == "" {
		errs = append(errs, errors.New("- KINTONE_BASE_URL must be provided"))
	} else if u, err := url.Parse(baseURL); err != nil {
		errs = append(errs, fmt.Errorf("- Failed to parse KINTONE_BASE_URL: %s", err))
	} else {
		handlers.URL = u
	}

	handlers.Allow = GetenvList("KINTONE_ALLOW_APPS")
	handlers.Deny = GetenvList("KINTONE_DENY_APPS")

	if len(errs) > 1 {
		return nil, errors.Join(errs...)
	}

	return &handlers, nil
}

type Query map[string]string

func (q Query) Encode() string {
	values := make(url.Values)
	for k, v := range q {
		values.Set(k, v)
	}
	return values.Encode()
}

func (h *KintoneHandlers) SendHTTP(ctx context.Context, method, path string, query Query, body io.Reader, contentType string) (*http.Response, error) {
	endpoint := h.URL.JoinPath(path)
	endpoint.RawQuery = query.Encode()

	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body)
	if err != nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InternalErrorCode,
			Message: fmt.Sprintf("Failed to create HTTP request: %v", err),
		}
	}

	if h.Auth != "" {
		req.Header.Set("X-Cybozu-Authorization", h.Auth)
	}
	if h.Token != "" {
		req.Header.Set("X-Cybozu-API-Token", h.Token)
	}
	if body != nil {
		req.Header.Set("Content-Type", contentType)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InternalErrorCode,
			Message: fmt.Sprintf("Failed to send HTTP request to kintone server: %v", err),
		}
	}

	if res.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(res.Body)
		res.Body.Close()
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InternalErrorCode,
			Message: fmt.Sprintf("kintone server returned an error: %s\n%s", res.Status, msg),
		}
	}

	return res, nil
}

func (h *KintoneHandlers) FetchHTTPWithReader(ctx context.Context, method, path string, query Query, body io.Reader, contentType string, result any) error {
	res, err := h.SendHTTP(ctx, method, path, query, body, contentType)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	if result != nil {
		if err := json.NewDecoder(res.Body).Decode(result); err != nil {
			return jsonrpc2.Error{
				Code:    jsonrpc2.InternalErrorCode,
				Message: fmt.Sprintf("Failed to parse kintone server's response: %v", err),
			}
		}
	}

	return nil
}

func (h *KintoneHandlers) FetchHTTPWithJSON(ctx context.Context, method, path string, query Query, body, result any) error {
	var reqBody io.Reader
	if body != nil {
		bs, err := json.Marshal(body)
		if err != nil {
			return jsonrpc2.Error{
				Code:    jsonrpc2.InternalErrorCode,
				Message: fmt.Sprintf("Failed to prepare request body for kintone server: %v", err),
			}
		}
		reqBody = bytes.NewReader(bs)
	}

	return h.FetchHTTPWithReader(ctx, method, path, query, reqBody, "application/json", result)
}

func (h *KintoneHandlers) InitializeHandler(ctx context.Context, params InitializeRequest) (InitializeResult, error) {
	version := "2025-03-26"
	if params.ProtocolVersion < version {
		version = params.ProtocolVersion
	}

	return InitializeResult{
		ProtocolVersion: version,
		Capabilities: JsonMap{
			"tools": JsonMap{},
		},
		ServerInfo: ServerInfo{
			Name:    "Kintone Server",
			Version: fmt.Sprintf("%s (%s)", Version, Commit),
		},
		Instructions: fmt.Sprintf("kintone is a database service to store and manage enterprise data. You can use this server to interact with kintone."),
	}, nil
}

//go:embed tools_list.json
var toolsListTmplStr string

var toolsList ToolsListResult

func init() {
	tmpl, err := template.New("tools_list").Parse(toolsListTmplStr)
	if err != nil {
		panic(fmt.Sprintf("Failed to parse tools list template: %v", err))
	}

	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, nil); err != nil {
		panic(fmt.Sprintf("Failed to render tools list template: %v", err))
	}

	if err := json.Unmarshal(buf.Bytes(), &toolsList); err != nil {
		panic(fmt.Sprintf("Failed to parse tools list JSON: %v", err))
	}
}

func (h *KintoneHandlers) ToolsList(ctx context.Context, params any) (ToolsListResult, error) {
	return toolsList, nil
}

func (h *KintoneHandlers) ToolsCall(ctx context.Context, params ToolsCallRequest) (ToolsCallResult, error) {
	var content []Content
	var err error

	switch params.Name {
	case "listApps":
		content, err = h.ListApps(ctx, params.Arguments)
	case "readAppInfo":
		content, err = h.ReadAppInfo(ctx, params.Arguments)
	case "createRecord":
		content, err = h.CreateRecord(ctx, params.Arguments)
	case "readRecords":
		content, err = h.ReadRecords(ctx, params.Arguments)
	case "updateRecord":
		content, err = h.UpdateRecord(ctx, params.Arguments)
	case "deleteRecord":
		content, err = h.DeleteRecord(ctx, params.Arguments)
	case "downloadAttachmentFile":
		content, err = h.DownloadAttachmentFile(ctx, params.Arguments)
	case "uploadAttachmentFile":
		content, err = h.UploadAttachmentFile(ctx, params.Arguments)
	case "readRecordComments":
		content, err = h.ReadRecordComments(ctx, params.Arguments)
	case "createRecordComment":
		content, err = h.CreateRecordComment(ctx, params.Arguments)
	case "updateProcessManagementAssignee":
		content, err = h.UpdateProcessManagementAssignee(ctx, params.Arguments)
	case "executeProcessManagementAction":
		content, err = h.ExecuteProcessManagementAction(ctx, params.Arguments)
	default:
		return ToolsCallResult{}, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: fmt.Sprintf("Unknown tool name: %s", params.Name),
		}
	}

	if err != nil {
		return ToolsCallResult{}, err
	}

	return ToolsCallResult{
		Content: content,
	}, nil
}

func (h *KintoneHandlers) checkPermissions(id string) error {
	if slices.Contains(h.Deny, id) {
		return jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: fmt.Sprintf("App ID %s is inaccessible because it is listed in the KINTONE_DENY_APPS environment variable. Please check the MCP server settings.", id),
		}
	}
	if len(h.Allow) > 0 && !slices.Contains(h.Allow, id) {
		return jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: fmt.Sprintf("App ID %s is inaccessible because it is not listed in the KINTONE_ALLOW_APPS environment variable. Please check the MCP server settings.", id),
		}
	}

	return nil
}

func (h *KintoneHandlers) ListApps(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		Offset int     `json:"offset"`
		Limit  *int    `json:"limit"`
		Name   *string `json:"name"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.Offset < 0 {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Offset must be greater than or equal to 0",
		}
	}
	if req.Limit == nil {
		limit := 100
		req.Limit = &limit
	} else if *req.Limit < 1 || *req.Limit > 100 {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Limit must be between 1 and 100",
		}
	}

	type Res struct {
		Apps []KintoneAppDetail `json:"apps"`
	}

	var httpRes Res
	err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/apps.json", nil, req, &httpRes)
	if err != nil {
		return nil, err
	}

	apps := make([]KintoneAppDetail, 0, len(httpRes.Apps))
	for _, app := range httpRes.Apps {
		if err := h.checkPermissions(app.AppID); err == nil {
			apps = append(apps, app)
		}
	}

	hasNext := false
	var httpRes2 Res
	err = h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/apps.json", nil, JsonMap{"offset": req.Offset + len(httpRes.Apps), "limit": 1}, &httpRes2)
	if err == nil {
		hasNext = len(httpRes2.Apps) > 0
	}

	return JSONContent(JsonMap{
		"apps":    apps,
		"hasNext": hasNext,
	})
}

func (h *KintoneHandlers) ReadAppInfo(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID string `json:"appID"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.AppID == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Argument 'appID' is required",
		}
	}

	if err := h.checkPermissions(req.AppID); err != nil {
		return nil, err
	}

	var app KintoneAppDetail
	if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/app.json", Query{"id": req.AppID}, nil, &app); err != nil {
		return nil, err
	}

	var fields struct {
		Properties JsonMap `json:"properties"`
	}
	if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/app/form/fields.json", Query{"app": req.AppID}, nil, &fields); err != nil {
		return nil, err
	}
	app.Properties = fields.Properties

	var process ProcessManagement
	if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/app/status.json", Query{"app": req.AppID}, nil, &process); err != nil {
		return nil, err
	}
	if !process.Enable {
		process.States = nil
		process.Actions = nil
	}
	app.ProcessManagement = process

	return JSONContent(app)
}

func (h *KintoneHandlers) CreateRecord(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID  string  `json:"appID"`
		Record JsonMap `json:"record"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.AppID == "" || req.Record == nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'appID' and 'record' are required",
		}
	}

	if err := h.checkPermissions(req.AppID); err != nil {
		return nil, err
	}

	httpReq := JsonMap{
		"app":    req.AppID,
		"record": req.Record,
	}
	var record struct {
		ID string `json:"id"`
	}
	if err := h.FetchHTTPWithJSON(ctx, "POST", "/k/v1/record.json", nil, httpReq, &record); err != nil {
		return nil, err
	}

	return JSONContent(JsonMap{
		"success":  true,
		"recordID": record.ID,
	})
}

func (h *KintoneHandlers) ReadRecords(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID  string   `json:"appID"`
		Query  string   `json:"query"`
		Limit  *int     `json:"limit"`
		Fields []string `json:"fields"`
		Offset int      `json:"offset"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.AppID == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Argument 'appID' is required",
		}
	}

	if req.Limit == nil {
		limit := 10
		req.Limit = &limit
	} else if *req.Limit < 1 || *req.Limit > 500 {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Limit must be between 1 and 500",
		}
	}

	if req.Offset < 0 || req.Offset > 10000 {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Offset must be between 0 and 10000",
		}
	}

	if err := h.checkPermissions(req.AppID); err != nil {
		return nil, err
	}

	httpReq := JsonMap{
		"app":        req.AppID,
		"query":      req.Query,
		"limit":      *req.Limit,
		"offset":     req.Offset,
		"fields":     req.Fields,
		"totalCount": true,
	}

	var records JsonMap
	if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/records.json", nil, httpReq, &records); err != nil {
		return nil, err
	}

	return JSONContent(records)
}

func (h *KintoneHandlers) UpdateRecord(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID    string `json:"appID"`
		RecordID string `json:"recordID"`
		Record   any    `json:"record"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.AppID == "" || req.RecordID == "" || req.Record == nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'appID', 'recordID', and 'record' are required",
		}
	}

	if err := h.checkPermissions(req.AppID); err != nil {
		return nil, err
	}

	httpReq := JsonMap{
		"app":    req.AppID,
		"id":     req.RecordID,
		"record": req.Record,
	}
	var result struct {
		Revision string `json:"revision"`
	}
	if err := h.FetchHTTPWithJSON(ctx, "PUT", "/k/v1/record.json", nil, httpReq, &result); err != nil {
		return nil, err
	}

	return JSONContent(JsonMap{
		"success":  true,
		"revision": result.Revision,
	})
}

func (h *KintoneHandlers) readSingleRecord(ctx context.Context, appID, recordID string) (JsonMap, error) {
	var result struct {
		Record JsonMap `json:"record"`
	}
	err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/record.json", Query{"app": appID, "id": recordID}, nil, &result)

	return result.Record, err
}

func (h *KintoneHandlers) DeleteRecord(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID    string `json:"appID"`
		RecordID string `json:"recordID"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.AppID == "" || req.RecordID == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'appID' and 'recordID' are required",
		}
	}

	if err := h.checkPermissions(req.AppID); err != nil {
		return nil, err
	}

	deletedRecord, err := h.readSingleRecord(ctx, req.AppID, req.RecordID)
	if err != nil {
		return nil, err
	}

	if err := h.FetchHTTPWithJSON(ctx, "DELETE", "/k/v1/records.json", Query{"app": req.AppID, "ids[0]": req.RecordID}, nil, nil); err != nil {
		return nil, err
	}

	result := JsonMap{
		"success": true,
	}
	if deletedRecord != nil {
		result["deletedRecord"] = deletedRecord
	}
	return JSONContent(result)
}

func getDownloadDirectory() string {
	dir, err := os.UserHomeDir()
	if err != nil {
		return os.TempDir()
	}

	for _, d := range []string{"Downloads", "downloads", "Download", "download"} {
		d = filepath.Join(dir, d)
		if _, err := os.Stat(d); err == nil {
			return d
		}
	}

	dir = filepath.Join(dir, "Downloads")
	err = os.MkdirAll(dir, 0755)
	if err != nil {
		return os.TempDir()
	}
	return dir
}

func getDownloadFilePath(fileName string) string {
	dir := getDownloadDirectory()

	p := filepath.Join(dir, fileName)
	if _, err := os.Stat(p); err != nil {
		return p
	}

	ext := filepath.Ext(fileName)
	base := strings.TrimSuffix(fileName, ext)

	num := 1
	if strings.HasSuffix(base, ")") {
		if i := strings.LastIndex(base, " ("); i > 0 {
			if n, err := strconv.Atoi(base[i+2:]); err == nil {
				base = base[:i]
				num = n
			}
		}
	}

	for {
		p = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, num, ext))
		if _, err := os.Stat(p); err != nil {
			return p
		}
		num++
	}
}

func (h *KintoneHandlers) DownloadAttachmentFile(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		FileKey string `json:"fileKey"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.FileKey == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Argument 'fileKey' is required",
		}
	}

	httpRes, err := h.SendHTTP(ctx, "GET", "/k/v1/file.json", Query{"fileKey": req.FileKey}, nil, "")
	if err != nil {
		return nil, err
	}
	defer httpRes.Body.Close()

	contentType := httpRes.Header.Get("Content-Type")
	if contentType == "" {
		contentType = "application/octet-stream"
	}

	var fileName string

	_, ps, err := mime.ParseMediaType(httpRes.Header.Get("Content-Disposition"))
	if err == nil {
		fileName = ps["filename"]
	}

	fileName, err = new(mime.WordDecoder).DecodeHeader(fileName)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Failed to decode filename: %v\n", err)
		fileName = ""
	}

	if fileName == "" {
		fileName = req.FileKey

		ext, err := mime.ExtensionsByType(contentType)
		if err == nil && len(ext) > 0 {
			fileName += ext[0]
		}
	}

	outPath := getDownloadFilePath(fileName)
	outFile, err := os.Create(outPath)
	if err != nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InternalErrorCode,
			Message: fmt.Sprintf("Failed to create file for attachment: %v", err),
			Data:    JsonMap{"filePath": outPath},
		}
	}
	defer outFile.Close()

	var w io.Writer = outFile
	var buf *bytes.Buffer
	if strings.HasPrefix(contentType, "text/") || strings.HasPrefix(contentType, "image/") {
		buf = new(bytes.Buffer)
		w = io.MultiWriter(outFile, buf)
	}

	size, err := io.Copy(w, httpRes.Body)
	if err != nil {
		outFile.Close()
		os.Remove(outPath)
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InternalErrorCode,
			Message: fmt.Sprintf("Failed to save attachment file: %s: %v", outPath, err),
		}
	}

	res, err := JSONContent(JsonMap{
		"success":  true,
		"filePath": outPath,
		"size":     size,
	})
	if err != nil {
		return nil, err
	}

	if strings.HasPrefix(contentType, "text/") {
		res = append(res, Content{Type: "text", Text: buf.String()})
	} else if strings.HasPrefix(contentType, "image/") {
		b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
		res = append(res, Content{
			Type:     "image",
			Data:     b64,
			MimeType: contentType,
		})
	}

	return res, nil
}

func (h *KintoneHandlers) UploadAttachmentFile(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		Path    *string `json:"path"`
		Name    string  `json:"name"`
		Content *string `json:"content"`
		Base64  bool    `json:"base64"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}

	if req.Path == nil && req.Content == nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'path' or 'content' is required",
		}
	}
	if req.Path != nil && req.Content != nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'path' and 'content' are mutually exclusive",
		}
	}

	var filename string
	if req.Path != nil {
		filename = filepath.Base(*req.Path)
	} else {
		filename = req.Name
		if filename == "" {
			filename = "file"

			ext, err := mime.ExtensionsByType(mime.TypeByExtension(filepath.Ext(req.Name)))
			if err == nil && len(ext) > 0 {
				filename += ext[0]
			}
		}
	}

	var body bytes.Buffer
	mw := multipart.NewWriter(&body)
	part, err := mw.CreateFormFile("file", filename)
	if err != nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InternalErrorCode,
			Message: fmt.Sprintf("Failed to prepare request: %v", err),
		}
	}

	if req.Path != nil {
		r, err := os.Open(*req.Path)
		if err != nil {
			return nil, jsonrpc2.Error{
				Code:    jsonrpc2.InternalErrorCode,
				Message: fmt.Sprintf("Failed to open file: %v", err),
			}
		}
		defer r.Close()

		if _, err := io.Copy(part, r); err != nil {
			return nil, jsonrpc2.Error{
				Code:    jsonrpc2.InternalErrorCode,
				Message: fmt.Sprintf("Failed to read file content: %v", err),
			}
		}
	} else if req.Base64 {
		r := base64.NewDecoder(base64.StdEncoding, strings.NewReader(*req.Content))
		if _, err := io.Copy(part, r); err != nil {
			return nil, jsonrpc2.Error{
				Code:    jsonrpc2.InternalErrorCode,
				Message: fmt.Sprintf("Failed to read file content: %v", err),
			}
		}
	} else {
		if _, err := part.Write([]byte(*req.Content)); err != nil {
			return nil, jsonrpc2.Error{
				Code:    jsonrpc2.InternalErrorCode,
				Message: fmt.Sprintf("Failed to read file content: %v", err),
			}
		}
	}

	if err := mw.Close(); err != nil {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InternalErrorCode,
			Message: fmt.Sprintf("Failed to finalize request: %v", err),
		}
	}

	var res struct {
		FileKey string `json:"fileKey"`
	}
	if err := h.FetchHTTPWithReader(ctx, "POST", "/k/v1/file.json", nil, &body, mw.FormDataContentType(), &res); err != nil {
		return nil, err
	}

	return JSONContent(JsonMap{
		"success": true,
		"fileKey": res.FileKey,
	})
}

func (h *KintoneHandlers) ReadRecordComments(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID    string `json:"appID"`
		RecordID string `json:"recordID"`
		Order    string `json:"order"`
		Offset   int    `json:"offset"`
		Limit    *int   `json:"limit"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}

	if req.AppID == "" || req.RecordID == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'appID' and 'recordID' are required",
		}
	}

	if req.Order == "" {
		req.Order = "desc"
	} else if req.Order != "asc" && req.Order != "desc" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Order must be 'asc' or 'desc'",
		}
	}

	if req.Offset < 0 {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Offset must be greater than or equal to 0",
		}
	}

	if req.Limit == nil {
		limit := 10
		req.Limit = &limit
	} else if *req.Limit < 0 || *req.Limit > 10 {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Limit must be between 1 and 10",
		}
	}

	if err := h.checkPermissions(req.AppID); err != nil {
		return nil, err
	}

	httpReq := JsonMap{
		"app":    req.AppID,
		"record": req.RecordID,
		"order":  req.Order,
		"offset": req.Offset,
		"limit":  *req.Limit,
	}
	var httpRes struct {
		Comments []JsonMap `json:"comments"`
		Older    bool      `json:"older"`
		Newer    bool      `json:"newer"`
	}
	if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/record/comments.json", nil, httpReq, &httpRes); err != nil {
		return nil, err
	}

	return JSONContent(JsonMap{
		"comments":            httpRes.Comments,
		"existsOlderComments": httpRes.Older,
		"existsNewerComments": httpRes.Newer,
	})
}

func (h *KintoneHandlers) CreateRecordComment(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID    string `json:"appID"`
		RecordID string `json:"recordID"`
		Comment  struct {
			Text     string `json:"text"`
			Mentions []struct {
				Code string `json:"code"`
				Type string `json:"type"`
			} `json:"mentions"`
		} `json:"comment"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}

	if req.AppID == "" || req.RecordID == "" || req.Comment.Text == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'appID', 'recordID', and 'comment.text' are required",
		}
	}

	for i, m := range req.Comment.Mentions {
		if m.Code == "" {
			return nil, jsonrpc2.Error{
				Code:    jsonrpc2.InvalidParamsCode,
				Message: "Mention code is required",
			}
		}
		if m.Type == "" {
			req.Comment.Mentions[i].Type = "USER"
		} else if m.Type != "USER" && m.Type != "GROUP" && m.Type != "ORGANIZATION" {
			return nil, jsonrpc2.Error{
				Code:    jsonrpc2.InvalidParamsCode,
				Message: "Mention type must be 'USER', 'GROUP', or 'ORGANIZATION'",
			}
		}
	}

	if err := h.checkPermissions(req.AppID); err != nil {
		return nil, err
	}

	httpReq := JsonMap{
		"app":     req.AppID,
		"record":  req.RecordID,
		"comment": req.Comment,
	}
	if err := h.FetchHTTPWithJSON(ctx, "POST", "/k/v1/record/comment.json", nil, httpReq, nil); err != nil {
		return nil, err
	}

	return JSONContent(JsonMap{
		"success": true,
	})
}

func (h *KintoneHandlers) UpdateProcessManagementAssignee(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID     string   `json:"appID"`
		RecordID  string   `json:"recordID"`
		Assignees []string `json:"assignees"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.AppID == "" || req.RecordID == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'appID' and 'recordID' are required",
		}
	}

	httpReq := JsonMap{
		"app":       req.AppID,
		"id":        req.RecordID,
		"assignees": req.Assignees,
	}
	if err := h.FetchHTTPWithJSON(ctx, "PUT", "/k/v1/record/assignees.json", nil, httpReq, nil); err != nil {
		return nil, err
	}

	return JSONContent(JsonMap{
		"success": true,
	})
}

func (h *KintoneHandlers) ExecuteProcessManagementAction(ctx context.Context, params json.RawMessage) ([]Content, error) {
	var req struct {
		AppID    string  `json:"appID"`
		RecordID string  `json:"recordID"`
		Action   string  `json:"action"`
		Assignee *string `json:"assignee"`
	}
	if err := UnmarshalParams(params, &req); err != nil {
		return nil, err
	}
	if req.AppID == "" || req.RecordID == "" || req.Action == "" {
		return nil, jsonrpc2.Error{
			Code:    jsonrpc2.InvalidParamsCode,
			Message: "Arguments 'appID', 'recordID', and 'action' are required",
		}
	}

	httpReq := JsonMap{
		"app":    req.AppID,
		"id":     req.RecordID,
		"action": req.Action,
	}
	if req.Assignee != nil {
		httpReq["assignee"] = *req.Assignee
	}
	if err := h.FetchHTTPWithJSON(ctx, "PUT", "/k/v1/record/status.json", nil, httpReq, nil); err != nil {
		return nil, err
	}

	return JSONContent(JsonMap{
		"success": true,
	})
}

func Getenv(key, defaultValue string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return defaultValue
}

func GetenvList(key string) []string {
	if v := os.Getenv(key); v != "" {
		raw := strings.Split(v, ",")
		ss := make([]string, 0, len(raw))
		for _, s := range raw {
			if s != "" {
				ss = append(ss, strings.TrimSpace(s))
			}
		}
		return ss
	}
	return nil
}

type MergedReadWriter struct {
	r io.Reader
	w io.Writer
}

func (rw *MergedReadWriter) Read(p []byte) (int, error) {
	return rw.r.Read(p)
}

func (rw *MergedReadWriter) Write(p []byte) (int, error) {
	return rw.w.Write(p)
}

func main() {
	handlers, err := NewKintoneHandlersFromEnv()
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s\n", err)
		os.Exit(1)
	}

	server := jsonrpc2.NewServer()
	server.On("initialize", jsonrpc2.Call(handlers.InitializeHandler))
	server.On("notifications/initialized", jsonrpc2.Notify(func(ctx context.Context, params any) error {
		return nil
	}))
	server.On("ping", jsonrpc2.Call(func(ctx context.Context, params any) (struct{}, error) {
		return struct{}{}, nil
	}))
	server.On("tools/list", jsonrpc2.Call(handlers.ToolsList))
	server.On("tools/call", jsonrpc2.Call(handlers.ToolsCall))

	fmt.Fprintf(os.Stderr, "kintone server is running on stdio!\n")

	server.ServeForOne(&MergedReadWriter{r: os.Stdin, w: os.Stdout})
}

```