# 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}) } ```