# 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: -------------------------------------------------------------------------------- ``` 1 | mcp-server-kintone 2 | mcp-server-kintone.exe 3 | ``` -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` 1 | Dockerfile 2 | mcp-server-kintone 3 | mcp-server-kintone.exe 4 | ``` -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- ```yaml 1 | before: 2 | hooks: 3 | - go mod tidy 4 | 5 | builds: 6 | - id: with-upx 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | flags: 12 | - -trimpath 13 | ldflags: 14 | - '-s -w' 15 | - '-X main.Version={{ .Version }}' 16 | - '-X main.Commit={{ .ShortCommit }}' 17 | hooks: 18 | post: 'upx-ucl --lzma {{ .Path }}' 19 | - id: without-upx 20 | env: 21 | - CGO_ENABLED=0 22 | goos: 23 | - windows 24 | - darwin 25 | flags: 26 | - -trimpath 27 | ldflags: 28 | - '-s -w' 29 | - '-X main.Version={{ .Version }}' 30 | - '-X main.Commit={{ .ShortCommit }}' 31 | 32 | archives: 33 | - format: tar.gz 34 | name_template: >- 35 | {{- .ProjectName -}} 36 | _ 37 | {{- .Version -}} 38 | _ 39 | {{- .Os -}} 40 | _ 41 | {{- if eq .Arch "386" -}} 42 | i386 43 | {{- else if eq .Arch "amd64" -}} 44 | x86_64 45 | {{- else -}} 46 | {{- .Arch -}} 47 | {{- end -}} 48 | format_overrides: 49 | - goos: windows 50 | format: zip 51 | files: [none*] 52 | 53 | changelog: 54 | filters: 55 | exclude: 56 | - '^chore' 57 | - '^docs' 58 | - '^test' 59 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM golang:latest AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod ./ 6 | 7 | RUN go mod download 8 | 9 | COPY . . 10 | 11 | RUN CGO_ENABLED=0 go build -o mcp-server-kintone 12 | 13 | 14 | ARG BASE_IMAGE 15 | FROM ${BASE_IMAGE:-alpine:latest} 16 | 17 | COPY --from=builder /app/mcp-server-kintone / 18 | 19 | ENTRYPOINT ["/mcp-server-kintone"] 20 | ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- ```yaml 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | steps: 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version: 1.23.x 17 | - uses: actions/checkout@v3 18 | - name: Test 19 | run: go test -race ./... 20 | 21 | analyze: 22 | name: CodeQL 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: github/codeql-action/init@v2 27 | with: 28 | languages: go 29 | - uses: github/codeql-action/analyze@v2 30 | 31 | release: 32 | name: Release 33 | needs: [test, analyze] 34 | if: "contains(github.ref, 'tags/v')" 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/setup-go@v3 38 | with: 39 | go-version: 1.23.x 40 | - uses: actions/checkout@v3 41 | with: 42 | fetch-depth: 0 43 | - name: Install upx-ucl 44 | run: sudo apt install upx-ucl -y 45 | - uses: goreleaser/goreleaser-action@v2 46 | with: 47 | version: latest 48 | args: release --clean 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | type: object 7 | anyOf: 8 | - required: [url, username, password] 9 | - required: [url, token] 10 | properties: 11 | url: 12 | type: string 13 | description: The URL of the kintone domain. e.g. https://example.kintone.com 14 | username: 15 | type: string 16 | description: The username for kintone authentication. This option and the password option are required if the token option is not provided. 17 | password: 18 | type: string 19 | description: The password for kintone authentication. This option and the username option are required if the token option is not provided. 20 | token: 21 | type: string 22 | description: The API token for kintone authentication. This option is required if the username and password options are not provided. 23 | allowApps: 24 | type: string 25 | description: A comma-separated list of app IDs to allow. If not provided, all apps are allowed. 26 | denyApps: 27 | type: string 28 | description: A comma-separated list of app IDs to deny. If not provided, no apps are denied. 29 | 30 | commandFunction: | 31 | config => ({ 32 | command: './mcp-server-kintone', 33 | env: { 34 | KINTONE_BASE_URL: config.url, 35 | KINTONE_USERNAME: config.username, 36 | KINTONE_PASSWORD: config.password, 37 | KINTONE_API_TOKEN: config.token, 38 | KINTONE_ALLOW_APPS: config.allowApps, 39 | KINTONE_DENY_APPS: config.denyApps, 40 | }, 41 | }) 42 | ``` -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | schedule: 10 | - cron: '18 11 * * *' 11 | push: 12 | branches: [ "main" ] 13 | # Publish semver tags as releases. 14 | tags: [ 'v*.*.*' ] 15 | pull_request: 16 | branches: [ "main" ] 17 | 18 | env: 19 | # Use docker.io for Docker Hub if empty 20 | REGISTRY: ghcr.io 21 | # github.repository as <account>/<repo> 22 | IMAGE_NAME: ${{ github.repository }} 23 | 24 | 25 | jobs: 26 | build: 27 | 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | packages: write 32 | # This is used to complete the identity challenge 33 | # with sigstore/fulcio when running outside of PRs. 34 | id-token: write 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | # Install the cosign tool except on PR 41 | # https://github.com/sigstore/cosign-installer 42 | - name: Install cosign 43 | if: github.event_name != 'pull_request' 44 | uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 45 | with: 46 | cosign-release: 'v2.2.4' 47 | 48 | # Set up BuildKit Docker container builder to be able to build 49 | # multi-platform images and export cache 50 | # https://github.com/docker/setup-buildx-action 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 53 | 54 | # Login against a Docker registry except on PR 55 | # https://github.com/docker/login-action 56 | - name: Log into registry ${{ env.REGISTRY }} 57 | if: github.event_name != 'pull_request' 58 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 59 | with: 60 | registry: ${{ env.REGISTRY }} 61 | username: ${{ github.actor }} 62 | password: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | # Extract metadata (tags, labels) for Docker 65 | # https://github.com/docker/metadata-action 66 | - name: Extract Docker metadata 67 | id: meta 68 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 69 | with: 70 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 71 | 72 | # Build and push Docker image with Buildx (don't push on PR) 73 | # https://github.com/docker/build-push-action 74 | - name: Build and push Docker image 75 | id: build-and-push 76 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 77 | with: 78 | context: . 79 | push: ${{ github.event_name != 'pull_request' }} 80 | tags: ${{ steps.meta.outputs.tags }} 81 | labels: ${{ steps.meta.outputs.labels }} 82 | cache-from: type=gha 83 | cache-to: type=gha,mode=max 84 | build-args: | 85 | BASE_IMAGE=scratch 86 | 87 | # Sign the resulting Docker image digest except on PRs. 88 | # This will only write to the public Rekor transparency log when the Docker 89 | # repository is public to avoid leaking data. If you would like to publish 90 | # transparency data even for private images, pass --force to cosign below. 91 | # https://github.com/sigstore/cosign 92 | - name: Sign the published Docker image 93 | if: ${{ github.event_name != 'pull_request' }} 94 | env: 95 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 96 | TAGS: ${{ steps.meta.outputs.tags }} 97 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 98 | # This step uses the identity token to provision an ephemeral certificate 99 | # against the sigstore community Fulcio instance. 100 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 101 | ``` -------------------------------------------------------------------------------- /tools_list.json: -------------------------------------------------------------------------------- ```json 1 | {{ define "kintoneRecordProperties" }} 2 | { 3 | "properties": { 4 | "value": { 5 | "anyOf": [ 6 | { 7 | "description": "Usual values for text, number, etc.", 8 | "type": "string" 9 | }, 10 | { 11 | "description": "Values for checkbox.", 12 | "items": { 13 | "type": "string" 14 | }, 15 | "type": "array" 16 | }, 17 | { 18 | "description": "Values for file attachment.", 19 | "items": { 20 | "properties": { 21 | "contentType": { 22 | "description": "The content type of the file.", 23 | "type": "string" 24 | }, 25 | "fileKey": { 26 | "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.", 27 | "type": "string" 28 | }, 29 | "name": { 30 | "description": "The file name.", 31 | "type": "string" 32 | } 33 | }, 34 | "type": "object" 35 | }, 36 | "type": "array" 37 | }, 38 | { 39 | "description": "Values for table.", 40 | "properties": { 41 | "value": { 42 | "items": { 43 | "properties": { 44 | "value": { 45 | "additionalProperties": { 46 | "properties": { 47 | "value": {} 48 | }, 49 | "required": [ 50 | "value" 51 | ], 52 | "type": "object" 53 | }, 54 | "type": "object" 55 | } 56 | }, 57 | "required": [ 58 | "value" 59 | ], 60 | "type": "object" 61 | }, 62 | "type": "array" 63 | } 64 | }, 65 | "required": [ 66 | "value" 67 | ], 68 | "type": "object" 69 | } 70 | ] 71 | } 72 | }, 73 | "required": [ 74 | "value" 75 | ], 76 | "type": "object" 77 | } 78 | {{ end }} 79 | 80 | { 81 | "tools": [ 82 | { 83 | "name": "listApps", 84 | "description": "List all applications made on kintone. Response includes the app ID, name, and description.", 85 | "inputSchema": { 86 | "properties": { 87 | "offset": { 88 | "description": "The offset of apps to read. Default is 0.", 89 | "type": "number" 90 | }, 91 | "limit": { 92 | "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.", 93 | "type": "number" 94 | }, 95 | "name": { 96 | "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.", 97 | "type": "string" 98 | } 99 | }, 100 | "type": "object" 101 | }, 102 | "annotations": { 103 | "title": "List kintone apps", 104 | "readOnlyHint": true, 105 | "openWorldHint": true 106 | } 107 | }, 108 | { 109 | "name": "readAppInfo", 110 | "description": "Get information about the specified app. Response includes the app ID, name, description, and schema.", 111 | "inputSchema": { 112 | "properties": { 113 | "appID": { 114 | "description": "The app ID to get information from.", 115 | "type": "string" 116 | } 117 | }, 118 | "required": [ 119 | "appID" 120 | ], 121 | "type": "object" 122 | }, 123 | "annotations": { 124 | "title": "Read kintone app information", 125 | "readOnlyHint": true, 126 | "openWorldHint": true 127 | } 128 | }, 129 | { 130 | "name": "createRecord", 131 | "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.", 132 | "inputSchema": { 133 | "properties": { 134 | "appID": { 135 | "description": "The app ID to create a record in.", 136 | "type": "string" 137 | }, 138 | "record": { 139 | "additionalProperties": {{ template "kintoneRecordProperties" }}, 140 | "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\"}}.", 141 | "type": "object" 142 | } 143 | }, 144 | "required": [ 145 | "appID", 146 | "record" 147 | ], 148 | "type": "object" 149 | }, 150 | "annotations": { 151 | "title": "Create a kintone record", 152 | "readOnlyHint": false, 153 | "destructiveHint": false, 154 | "idempotentHint": false, 155 | "openWorldHint": true 156 | } 157 | }, 158 | { 159 | "name": "readRecords", 160 | "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.", 161 | "inputSchema": { 162 | "properties": { 163 | "appID": { 164 | "description": "The app ID to read records from.", 165 | "type": "string" 166 | }, 167 | "fields": { 168 | "description": "The field codes to include in the response. Default is all fields.", 169 | "items": { 170 | "type": "string" 171 | }, 172 | "type": "array" 173 | }, 174 | "limit": { 175 | "description": "The maximum number of records to read. Default is 10, maximum is 500.", 176 | "type": "number" 177 | }, 178 | "offset": { 179 | "description": "The offset of records to read. Default is 0, maximum is 10,000.", 180 | "type": "number" 181 | }, 182 | "query": { 183 | "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\"'.", 184 | "type": "string" 185 | } 186 | }, 187 | "required": [ 188 | "appID" 189 | ], 190 | "type": "object" 191 | }, 192 | "annotations": { 193 | "title": "Read kintone records", 194 | "readOnlyHint": true, 195 | "openWorldHint": true 196 | } 197 | }, 198 | { 199 | "name": "updateRecord", 200 | "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.", 201 | "inputSchema": { 202 | "properties": { 203 | "appID": { 204 | "description": "The app ID to update a record in.", 205 | "type": "string" 206 | }, 207 | "record": { 208 | "additionalProperties": {{ template "kintoneRecordProperties" }}, 209 | "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.", 210 | "type": "object" 211 | }, 212 | "recordID": { 213 | "description": "The record ID to update.", 214 | "type": "string" 215 | } 216 | }, 217 | "required": [ 218 | "appID", 219 | "recordID", 220 | "record" 221 | ], 222 | "type": "object" 223 | }, 224 | "annotations": { 225 | "title": "Update a kintone record", 226 | "readOnlyHint": false, 227 | "destructiveHint": true, 228 | "idempotentHint": true, 229 | "openWorldHint": true 230 | } 231 | }, 232 | { 233 | "name": "deleteRecord", 234 | "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.", 235 | "inputSchema": { 236 | "properties": { 237 | "appID": { 238 | "description": "The app ID to delete a record from.", 239 | "type": "string" 240 | }, 241 | "recordID": { 242 | "description": "The record ID to delete.", 243 | "type": "string" 244 | } 245 | }, 246 | "required": [ 247 | "appID", 248 | "recordID" 249 | ], 250 | "type": "object" 251 | }, 252 | "annotations": { 253 | "title": "Delete a kintone records", 254 | "readOnlyHint": false, 255 | "destructiveHint": true, 256 | "idempotentHint": true, 257 | "openWorldHint": true 258 | } 259 | }, 260 | { 261 | "name": "downloadAttachmentFile", 262 | "description": "Download the specified attachment file. Before use this tool, you should check file key by using 'readRecords' tool.", 263 | "inputSchema": { 264 | "properties": { 265 | "fileKey": { 266 | "description": "The file key to download.", 267 | "type": "string" 268 | } 269 | }, 270 | "required": [ 271 | "fileKey" 272 | ], 273 | "type": "object" 274 | }, 275 | "annotations": { 276 | "title": "Download a file from kintone", 277 | "readOnlyHint": false, 278 | "destructiveHint": false, 279 | "idempotentHint": false, 280 | "openWorldHint": true 281 | } 282 | }, 283 | { 284 | "name": "uploadAttachmentFile", 285 | "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.", 286 | "inputSchema": { 287 | "description": "The file to upload. You can specify the file by path or content.", 288 | "properties": { 289 | "path": { 290 | "description": "The path of the file to upload. Required if `content` is not specified.", 291 | "type": "string" 292 | }, 293 | "content": { 294 | "description": "The content of the file to upload. Required if `path` is not specified.", 295 | "type": "string" 296 | }, 297 | "name": { 298 | "description": "The file name for the `content`. This is only used when `content` is specified.", 299 | "type": "string" 300 | }, 301 | "base64": { 302 | "description": "The `content` is base64 encoded or not. Default is false. This is only used when `content` is specified.", 303 | "type": "boolean" 304 | } 305 | }, 306 | "type": "object" 307 | }, 308 | "annotations": { 309 | "title": "Upload a file to kintone", 310 | "readOnlyHint": false, 311 | "destructiveHint": false, 312 | "idempotentHint": true, 313 | "openWorldHint": true 314 | } 315 | }, 316 | { 317 | "name": "readRecordComments", 318 | "description": "Read comments on the specified record in the specified app.", 319 | "inputSchema": { 320 | "properties": { 321 | "appID": { 322 | "description": "The app ID to read comments from.", 323 | "type": "string" 324 | }, 325 | "limit": { 326 | "description": "The maximum number of comments to read. Default is 10, maximum is 10.", 327 | "type": "number" 328 | }, 329 | "offset": { 330 | "description": "The offset of comments to read. Default is 0.", 331 | "type": "number" 332 | }, 333 | "order": { 334 | "description": "The order of comments. Default is 'desc'.", 335 | "type": "string" 336 | }, 337 | "recordID": { 338 | "description": "The record ID to read comments from.", 339 | "type": "string" 340 | } 341 | }, 342 | "required": [ 343 | "appID", 344 | "recordID" 345 | ], 346 | "type": "object" 347 | }, 348 | "annotations": { 349 | "title": "Read kintone record's comments", 350 | "readOnlyHint": true, 351 | "openWorldHint": true 352 | } 353 | }, 354 | { 355 | "name": "createRecordComment", 356 | "description": "Create a new comment on the specified record in the specified app.", 357 | "inputSchema": { 358 | "properties": { 359 | "appID": { 360 | "description": "The app ID to create a comment in.", 361 | "type": "string" 362 | }, 363 | "comment": { 364 | "properties": { 365 | "mentions": { 366 | "description": "The mention targets of the comment. The target can be a user, a group, or a organization.", 367 | "items": { 368 | "properties": { 369 | "code": { 370 | "description": "The code of the mention target. You can get the code by other records or comments.", 371 | "type": "string" 372 | }, 373 | "type": { 374 | "description": "The type of the mention target. Default is 'USER'.", 375 | "enum": [ 376 | "USER", 377 | "GROUP", 378 | "ORGANIZATION" 379 | ], 380 | "type": "string" 381 | } 382 | }, 383 | "required": [ 384 | "code" 385 | ], 386 | "type": "object" 387 | }, 388 | "type": "array" 389 | }, 390 | "text": { 391 | "description": "The text of the comment.", 392 | "type": "string" 393 | } 394 | }, 395 | "required": [ 396 | "text" 397 | ], 398 | "type": "object" 399 | }, 400 | "recordID": { 401 | "description": "The record ID to create a comment on.", 402 | "type": "string" 403 | } 404 | }, 405 | "required": [ 406 | "appID", 407 | "recordID", 408 | "comment" 409 | ], 410 | "type": "object" 411 | }, 412 | "annotations": { 413 | "title": "Post a comment to kintone record", 414 | "readOnlyHint": false, 415 | "destructiveHint": false, 416 | "idempotentHint": false, 417 | "openWorldHint": true 418 | } 419 | }, 420 | { 421 | "name": "updateProcessManagementAssignee", 422 | "description": "Update the assignee of process management of the specified record in the specified app.", 423 | "inputSchema": { 424 | "properties": { 425 | "appID": { 426 | "description": "The app ID to update the assignee.", 427 | "type": "string" 428 | }, 429 | "recordID": { 430 | "description": "The record ID to update the assignee.", 431 | "type": "string" 432 | }, 433 | "assignees": { 434 | "description": "The assignee of the record.", 435 | "type": "array", 436 | "items": { 437 | "type": "string", 438 | "description": "The code of the assignee user." 439 | } 440 | } 441 | }, 442 | "required": [ 443 | "appID", 444 | "recordID", 445 | "assignees" 446 | ], 447 | "type": "object" 448 | }, 449 | "annotations": { 450 | "title": "Update kintone record's assignee", 451 | "readOnlyHint": false, 452 | "destructiveHint": true, 453 | "idempotentHint": true, 454 | "openWorldHint": true 455 | } 456 | }, 457 | { 458 | "name": "executeProcessManagementAction", 459 | "description": "Execute the specified action of process management of the specified record in the specified app.", 460 | "inputSchema": { 461 | "properties": { 462 | "appID": { 463 | "description": "The app ID to execute the action.", 464 | "type": "string" 465 | }, 466 | "recordID": { 467 | "description": "The record ID to execute the action.", 468 | "type": "string" 469 | }, 470 | "action": { 471 | "description": "The action to execute.", 472 | "type": "string" 473 | }, 474 | "assignee": { 475 | "description": "The next assignee of the record.", 476 | "type": "string" 477 | } 478 | }, 479 | "required": [ 480 | "appID", 481 | "recordID", 482 | "action" 483 | ], 484 | "type": "object" 485 | }, 486 | "annotations": { 487 | "title": "Execute kintone record's process management action", 488 | "readOnlyHint": false, 489 | "destructiveHint": true, 490 | "idempotentHint": false, 491 | "openWorldHint": true 492 | } 493 | } 494 | ] 495 | } 496 | {{/* vim: set ft=json et ts=2 sw=2: */}} 497 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | _ "embed" 7 | "encoding/base64" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "mime" 13 | "mime/multipart" 14 | "net/http" 15 | "net/url" 16 | "os" 17 | "path/filepath" 18 | "slices" 19 | "strconv" 20 | "strings" 21 | "text/template" 22 | 23 | "github.com/macrat/go-jsonrpc2" 24 | ) 25 | 26 | var ( 27 | Version = "UNKNOWN" 28 | Commit = "HEAD" 29 | ) 30 | 31 | type JsonMap map[string]any 32 | 33 | type ServerInfo struct { 34 | Name string `json:"name"` 35 | Version string `json:"version"` 36 | } 37 | 38 | type InitializeRequest struct { 39 | ProtocolVersion string `json:"protocolVersion"` 40 | } 41 | 42 | type InitializeResult struct { 43 | ProtocolVersion string `json:"protocolVersion"` 44 | Capabilities JsonMap `json:"capabilities"` 45 | ServerInfo ServerInfo `json:"serverInfo"` 46 | Instructions string `json:"instructions"` 47 | } 48 | 49 | type Content struct { 50 | Type string `json:"type"` 51 | Text string `json:"text,omitempty"` 52 | Data string `json:"data,omitempty"` 53 | MimeType string `json:"mimeType,omitempty"` 54 | } 55 | 56 | func JSONContent(v any) ([]Content, error) { 57 | bs, err := json.MarshalIndent(v, "", " ") 58 | if err != nil { 59 | return nil, err 60 | } 61 | return []Content{{Type: "text", Text: string(bs)}}, nil 62 | } 63 | 64 | type ToolInfo struct { 65 | Name string `json:"name"` 66 | Description string `json:"description,omitempty"` 67 | InputSchema JsonMap `json:"inputSchema"` 68 | } 69 | 70 | type ToolsListResult struct { 71 | Tools []ToolInfo `json:"tools"` 72 | } 73 | 74 | type ToolsCallRequest struct { 75 | Name string `json:"name"` 76 | Arguments json.RawMessage `json:"arguments"` 77 | } 78 | 79 | type ToolsCallResult struct { 80 | Content []Content `json:"content"` 81 | IsError bool `json:"isError"` 82 | } 83 | 84 | func UnmarshalParams[T any](data []byte, target *T) error { 85 | err := json.Unmarshal(data, target) 86 | if err != nil { 87 | return jsonrpc2.Error{ 88 | Code: jsonrpc2.InvalidParamsCode, 89 | Message: fmt.Sprintf("Failed to parse parameters: %v", err), 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | type ProcessManagement struct { 96 | Enable bool `json:"enable"` 97 | States map[string]JsonMap `json:"states,omitempty"` 98 | Actions []JsonMap `json:"actions,omitempty"` 99 | } 100 | 101 | type KintoneAppDetail struct { 102 | AppID string `json:"appID"` 103 | Name string `json:"name"` 104 | Description string `json:"description,omitempty"` 105 | Properties JsonMap `json:"properties,omitempty"` 106 | CreatedAt string `json:"createdAt"` 107 | ModifiedAt string `json:"modifiedAt"` 108 | ProcessManagement ProcessManagement `json:"processManagement,omitempty"` 109 | } 110 | 111 | type KintoneHandlers struct { 112 | URL *url.URL 113 | Auth string 114 | Token string 115 | Allow []string 116 | Deny []string 117 | } 118 | 119 | func NewKintoneHandlersFromEnv() (*KintoneHandlers, error) { 120 | var handlers KintoneHandlers 121 | errs := []error{errors.New("Error:")} 122 | 123 | username := Getenv("KINTONE_USERNAME", "") 124 | password := Getenv("KINTONE_PASSWORD", "") 125 | tokens := Getenv("KINTONE_API_TOKEN", "") 126 | if (username == "" || password == "") && tokens == "" { 127 | errs = append(errs, errors.New("- Either KINTONE_USERNAME/KINTONE_PASSWORD or KINTONE_API_TOKEN must be provided")) 128 | } 129 | if username != "" && password != "" { 130 | handlers.Auth = base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", username, password)) 131 | } 132 | handlers.Token = tokens 133 | 134 | baseURL := Getenv("KINTONE_BASE_URL", "") 135 | if baseURL == "" { 136 | errs = append(errs, errors.New("- KINTONE_BASE_URL must be provided")) 137 | } else if u, err := url.Parse(baseURL); err != nil { 138 | errs = append(errs, fmt.Errorf("- Failed to parse KINTONE_BASE_URL: %s", err)) 139 | } else { 140 | handlers.URL = u 141 | } 142 | 143 | handlers.Allow = GetenvList("KINTONE_ALLOW_APPS") 144 | handlers.Deny = GetenvList("KINTONE_DENY_APPS") 145 | 146 | if len(errs) > 1 { 147 | return nil, errors.Join(errs...) 148 | } 149 | 150 | return &handlers, nil 151 | } 152 | 153 | type Query map[string]string 154 | 155 | func (q Query) Encode() string { 156 | values := make(url.Values) 157 | for k, v := range q { 158 | values.Set(k, v) 159 | } 160 | return values.Encode() 161 | } 162 | 163 | func (h *KintoneHandlers) SendHTTP(ctx context.Context, method, path string, query Query, body io.Reader, contentType string) (*http.Response, error) { 164 | endpoint := h.URL.JoinPath(path) 165 | endpoint.RawQuery = query.Encode() 166 | 167 | req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) 168 | if err != nil { 169 | return nil, jsonrpc2.Error{ 170 | Code: jsonrpc2.InternalErrorCode, 171 | Message: fmt.Sprintf("Failed to create HTTP request: %v", err), 172 | } 173 | } 174 | 175 | if h.Auth != "" { 176 | req.Header.Set("X-Cybozu-Authorization", h.Auth) 177 | } 178 | if h.Token != "" { 179 | req.Header.Set("X-Cybozu-API-Token", h.Token) 180 | } 181 | if body != nil { 182 | req.Header.Set("Content-Type", contentType) 183 | } 184 | 185 | res, err := http.DefaultClient.Do(req) 186 | if err != nil { 187 | return nil, jsonrpc2.Error{ 188 | Code: jsonrpc2.InternalErrorCode, 189 | Message: fmt.Sprintf("Failed to send HTTP request to kintone server: %v", err), 190 | } 191 | } 192 | 193 | if res.StatusCode != http.StatusOK { 194 | msg, _ := io.ReadAll(res.Body) 195 | res.Body.Close() 196 | return nil, jsonrpc2.Error{ 197 | Code: jsonrpc2.InternalErrorCode, 198 | Message: fmt.Sprintf("kintone server returned an error: %s\n%s", res.Status, msg), 199 | } 200 | } 201 | 202 | return res, nil 203 | } 204 | 205 | func (h *KintoneHandlers) FetchHTTPWithReader(ctx context.Context, method, path string, query Query, body io.Reader, contentType string, result any) error { 206 | res, err := h.SendHTTP(ctx, method, path, query, body, contentType) 207 | if err != nil { 208 | return err 209 | } 210 | defer res.Body.Close() 211 | 212 | if result != nil { 213 | if err := json.NewDecoder(res.Body).Decode(result); err != nil { 214 | return jsonrpc2.Error{ 215 | Code: jsonrpc2.InternalErrorCode, 216 | Message: fmt.Sprintf("Failed to parse kintone server's response: %v", err), 217 | } 218 | } 219 | } 220 | 221 | return nil 222 | } 223 | 224 | func (h *KintoneHandlers) FetchHTTPWithJSON(ctx context.Context, method, path string, query Query, body, result any) error { 225 | var reqBody io.Reader 226 | if body != nil { 227 | bs, err := json.Marshal(body) 228 | if err != nil { 229 | return jsonrpc2.Error{ 230 | Code: jsonrpc2.InternalErrorCode, 231 | Message: fmt.Sprintf("Failed to prepare request body for kintone server: %v", err), 232 | } 233 | } 234 | reqBody = bytes.NewReader(bs) 235 | } 236 | 237 | return h.FetchHTTPWithReader(ctx, method, path, query, reqBody, "application/json", result) 238 | } 239 | 240 | func (h *KintoneHandlers) InitializeHandler(ctx context.Context, params InitializeRequest) (InitializeResult, error) { 241 | version := "2025-03-26" 242 | if params.ProtocolVersion < version { 243 | version = params.ProtocolVersion 244 | } 245 | 246 | return InitializeResult{ 247 | ProtocolVersion: version, 248 | Capabilities: JsonMap{ 249 | "tools": JsonMap{}, 250 | }, 251 | ServerInfo: ServerInfo{ 252 | Name: "Kintone Server", 253 | Version: fmt.Sprintf("%s (%s)", Version, Commit), 254 | }, 255 | Instructions: fmt.Sprintf("kintone is a database service to store and manage enterprise data. You can use this server to interact with kintone."), 256 | }, nil 257 | } 258 | 259 | //go:embed tools_list.json 260 | var toolsListTmplStr string 261 | 262 | var toolsList ToolsListResult 263 | 264 | func init() { 265 | tmpl, err := template.New("tools_list").Parse(toolsListTmplStr) 266 | if err != nil { 267 | panic(fmt.Sprintf("Failed to parse tools list template: %v", err)) 268 | } 269 | 270 | var buf bytes.Buffer 271 | if err := tmpl.Execute(&buf, nil); err != nil { 272 | panic(fmt.Sprintf("Failed to render tools list template: %v", err)) 273 | } 274 | 275 | if err := json.Unmarshal(buf.Bytes(), &toolsList); err != nil { 276 | panic(fmt.Sprintf("Failed to parse tools list JSON: %v", err)) 277 | } 278 | } 279 | 280 | func (h *KintoneHandlers) ToolsList(ctx context.Context, params any) (ToolsListResult, error) { 281 | return toolsList, nil 282 | } 283 | 284 | func (h *KintoneHandlers) ToolsCall(ctx context.Context, params ToolsCallRequest) (ToolsCallResult, error) { 285 | var content []Content 286 | var err error 287 | 288 | switch params.Name { 289 | case "listApps": 290 | content, err = h.ListApps(ctx, params.Arguments) 291 | case "readAppInfo": 292 | content, err = h.ReadAppInfo(ctx, params.Arguments) 293 | case "createRecord": 294 | content, err = h.CreateRecord(ctx, params.Arguments) 295 | case "readRecords": 296 | content, err = h.ReadRecords(ctx, params.Arguments) 297 | case "updateRecord": 298 | content, err = h.UpdateRecord(ctx, params.Arguments) 299 | case "deleteRecord": 300 | content, err = h.DeleteRecord(ctx, params.Arguments) 301 | case "downloadAttachmentFile": 302 | content, err = h.DownloadAttachmentFile(ctx, params.Arguments) 303 | case "uploadAttachmentFile": 304 | content, err = h.UploadAttachmentFile(ctx, params.Arguments) 305 | case "readRecordComments": 306 | content, err = h.ReadRecordComments(ctx, params.Arguments) 307 | case "createRecordComment": 308 | content, err = h.CreateRecordComment(ctx, params.Arguments) 309 | case "updateProcessManagementAssignee": 310 | content, err = h.UpdateProcessManagementAssignee(ctx, params.Arguments) 311 | case "executeProcessManagementAction": 312 | content, err = h.ExecuteProcessManagementAction(ctx, params.Arguments) 313 | default: 314 | return ToolsCallResult{}, jsonrpc2.Error{ 315 | Code: jsonrpc2.InvalidParamsCode, 316 | Message: fmt.Sprintf("Unknown tool name: %s", params.Name), 317 | } 318 | } 319 | 320 | if err != nil { 321 | return ToolsCallResult{}, err 322 | } 323 | 324 | return ToolsCallResult{ 325 | Content: content, 326 | }, nil 327 | } 328 | 329 | func (h *KintoneHandlers) checkPermissions(id string) error { 330 | if slices.Contains(h.Deny, id) { 331 | return jsonrpc2.Error{ 332 | Code: jsonrpc2.InvalidParamsCode, 333 | 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), 334 | } 335 | } 336 | if len(h.Allow) > 0 && !slices.Contains(h.Allow, id) { 337 | return jsonrpc2.Error{ 338 | Code: jsonrpc2.InvalidParamsCode, 339 | 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), 340 | } 341 | } 342 | 343 | return nil 344 | } 345 | 346 | func (h *KintoneHandlers) ListApps(ctx context.Context, params json.RawMessage) ([]Content, error) { 347 | var req struct { 348 | Offset int `json:"offset"` 349 | Limit *int `json:"limit"` 350 | Name *string `json:"name"` 351 | } 352 | if err := UnmarshalParams(params, &req); err != nil { 353 | return nil, err 354 | } 355 | if req.Offset < 0 { 356 | return nil, jsonrpc2.Error{ 357 | Code: jsonrpc2.InvalidParamsCode, 358 | Message: "Offset must be greater than or equal to 0", 359 | } 360 | } 361 | if req.Limit == nil { 362 | limit := 100 363 | req.Limit = &limit 364 | } else if *req.Limit < 1 || *req.Limit > 100 { 365 | return nil, jsonrpc2.Error{ 366 | Code: jsonrpc2.InvalidParamsCode, 367 | Message: "Limit must be between 1 and 100", 368 | } 369 | } 370 | 371 | type Res struct { 372 | Apps []KintoneAppDetail `json:"apps"` 373 | } 374 | 375 | var httpRes Res 376 | err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/apps.json", nil, req, &httpRes) 377 | if err != nil { 378 | return nil, err 379 | } 380 | 381 | apps := make([]KintoneAppDetail, 0, len(httpRes.Apps)) 382 | for _, app := range httpRes.Apps { 383 | if err := h.checkPermissions(app.AppID); err == nil { 384 | apps = append(apps, app) 385 | } 386 | } 387 | 388 | hasNext := false 389 | var httpRes2 Res 390 | err = h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/apps.json", nil, JsonMap{"offset": req.Offset + len(httpRes.Apps), "limit": 1}, &httpRes2) 391 | if err == nil { 392 | hasNext = len(httpRes2.Apps) > 0 393 | } 394 | 395 | return JSONContent(JsonMap{ 396 | "apps": apps, 397 | "hasNext": hasNext, 398 | }) 399 | } 400 | 401 | func (h *KintoneHandlers) ReadAppInfo(ctx context.Context, params json.RawMessage) ([]Content, error) { 402 | var req struct { 403 | AppID string `json:"appID"` 404 | } 405 | if err := UnmarshalParams(params, &req); err != nil { 406 | return nil, err 407 | } 408 | if req.AppID == "" { 409 | return nil, jsonrpc2.Error{ 410 | Code: jsonrpc2.InvalidParamsCode, 411 | Message: "Argument 'appID' is required", 412 | } 413 | } 414 | 415 | if err := h.checkPermissions(req.AppID); err != nil { 416 | return nil, err 417 | } 418 | 419 | var app KintoneAppDetail 420 | if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/app.json", Query{"id": req.AppID}, nil, &app); err != nil { 421 | return nil, err 422 | } 423 | 424 | var fields struct { 425 | Properties JsonMap `json:"properties"` 426 | } 427 | if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/app/form/fields.json", Query{"app": req.AppID}, nil, &fields); err != nil { 428 | return nil, err 429 | } 430 | app.Properties = fields.Properties 431 | 432 | var process ProcessManagement 433 | if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/app/status.json", Query{"app": req.AppID}, nil, &process); err != nil { 434 | return nil, err 435 | } 436 | if !process.Enable { 437 | process.States = nil 438 | process.Actions = nil 439 | } 440 | app.ProcessManagement = process 441 | 442 | return JSONContent(app) 443 | } 444 | 445 | func (h *KintoneHandlers) CreateRecord(ctx context.Context, params json.RawMessage) ([]Content, error) { 446 | var req struct { 447 | AppID string `json:"appID"` 448 | Record JsonMap `json:"record"` 449 | } 450 | if err := UnmarshalParams(params, &req); err != nil { 451 | return nil, err 452 | } 453 | if req.AppID == "" || req.Record == nil { 454 | return nil, jsonrpc2.Error{ 455 | Code: jsonrpc2.InvalidParamsCode, 456 | Message: "Arguments 'appID' and 'record' are required", 457 | } 458 | } 459 | 460 | if err := h.checkPermissions(req.AppID); err != nil { 461 | return nil, err 462 | } 463 | 464 | httpReq := JsonMap{ 465 | "app": req.AppID, 466 | "record": req.Record, 467 | } 468 | var record struct { 469 | ID string `json:"id"` 470 | } 471 | if err := h.FetchHTTPWithJSON(ctx, "POST", "/k/v1/record.json", nil, httpReq, &record); err != nil { 472 | return nil, err 473 | } 474 | 475 | return JSONContent(JsonMap{ 476 | "success": true, 477 | "recordID": record.ID, 478 | }) 479 | } 480 | 481 | func (h *KintoneHandlers) ReadRecords(ctx context.Context, params json.RawMessage) ([]Content, error) { 482 | var req struct { 483 | AppID string `json:"appID"` 484 | Query string `json:"query"` 485 | Limit *int `json:"limit"` 486 | Fields []string `json:"fields"` 487 | Offset int `json:"offset"` 488 | } 489 | if err := UnmarshalParams(params, &req); err != nil { 490 | return nil, err 491 | } 492 | if req.AppID == "" { 493 | return nil, jsonrpc2.Error{ 494 | Code: jsonrpc2.InvalidParamsCode, 495 | Message: "Argument 'appID' is required", 496 | } 497 | } 498 | 499 | if req.Limit == nil { 500 | limit := 10 501 | req.Limit = &limit 502 | } else if *req.Limit < 1 || *req.Limit > 500 { 503 | return nil, jsonrpc2.Error{ 504 | Code: jsonrpc2.InvalidParamsCode, 505 | Message: "Limit must be between 1 and 500", 506 | } 507 | } 508 | 509 | if req.Offset < 0 || req.Offset > 10000 { 510 | return nil, jsonrpc2.Error{ 511 | Code: jsonrpc2.InvalidParamsCode, 512 | Message: "Offset must be between 0 and 10000", 513 | } 514 | } 515 | 516 | if err := h.checkPermissions(req.AppID); err != nil { 517 | return nil, err 518 | } 519 | 520 | httpReq := JsonMap{ 521 | "app": req.AppID, 522 | "query": req.Query, 523 | "limit": *req.Limit, 524 | "offset": req.Offset, 525 | "fields": req.Fields, 526 | "totalCount": true, 527 | } 528 | 529 | var records JsonMap 530 | if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/records.json", nil, httpReq, &records); err != nil { 531 | return nil, err 532 | } 533 | 534 | return JSONContent(records) 535 | } 536 | 537 | func (h *KintoneHandlers) UpdateRecord(ctx context.Context, params json.RawMessage) ([]Content, error) { 538 | var req struct { 539 | AppID string `json:"appID"` 540 | RecordID string `json:"recordID"` 541 | Record any `json:"record"` 542 | } 543 | if err := UnmarshalParams(params, &req); err != nil { 544 | return nil, err 545 | } 546 | if req.AppID == "" || req.RecordID == "" || req.Record == nil { 547 | return nil, jsonrpc2.Error{ 548 | Code: jsonrpc2.InvalidParamsCode, 549 | Message: "Arguments 'appID', 'recordID', and 'record' are required", 550 | } 551 | } 552 | 553 | if err := h.checkPermissions(req.AppID); err != nil { 554 | return nil, err 555 | } 556 | 557 | httpReq := JsonMap{ 558 | "app": req.AppID, 559 | "id": req.RecordID, 560 | "record": req.Record, 561 | } 562 | var result struct { 563 | Revision string `json:"revision"` 564 | } 565 | if err := h.FetchHTTPWithJSON(ctx, "PUT", "/k/v1/record.json", nil, httpReq, &result); err != nil { 566 | return nil, err 567 | } 568 | 569 | return JSONContent(JsonMap{ 570 | "success": true, 571 | "revision": result.Revision, 572 | }) 573 | } 574 | 575 | func (h *KintoneHandlers) readSingleRecord(ctx context.Context, appID, recordID string) (JsonMap, error) { 576 | var result struct { 577 | Record JsonMap `json:"record"` 578 | } 579 | err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/record.json", Query{"app": appID, "id": recordID}, nil, &result) 580 | 581 | return result.Record, err 582 | } 583 | 584 | func (h *KintoneHandlers) DeleteRecord(ctx context.Context, params json.RawMessage) ([]Content, error) { 585 | var req struct { 586 | AppID string `json:"appID"` 587 | RecordID string `json:"recordID"` 588 | } 589 | if err := UnmarshalParams(params, &req); err != nil { 590 | return nil, err 591 | } 592 | if req.AppID == "" || req.RecordID == "" { 593 | return nil, jsonrpc2.Error{ 594 | Code: jsonrpc2.InvalidParamsCode, 595 | Message: "Arguments 'appID' and 'recordID' are required", 596 | } 597 | } 598 | 599 | if err := h.checkPermissions(req.AppID); err != nil { 600 | return nil, err 601 | } 602 | 603 | deletedRecord, err := h.readSingleRecord(ctx, req.AppID, req.RecordID) 604 | if err != nil { 605 | return nil, err 606 | } 607 | 608 | if err := h.FetchHTTPWithJSON(ctx, "DELETE", "/k/v1/records.json", Query{"app": req.AppID, "ids[0]": req.RecordID}, nil, nil); err != nil { 609 | return nil, err 610 | } 611 | 612 | result := JsonMap{ 613 | "success": true, 614 | } 615 | if deletedRecord != nil { 616 | result["deletedRecord"] = deletedRecord 617 | } 618 | return JSONContent(result) 619 | } 620 | 621 | func getDownloadDirectory() string { 622 | dir, err := os.UserHomeDir() 623 | if err != nil { 624 | return os.TempDir() 625 | } 626 | 627 | for _, d := range []string{"Downloads", "downloads", "Download", "download"} { 628 | d = filepath.Join(dir, d) 629 | if _, err := os.Stat(d); err == nil { 630 | return d 631 | } 632 | } 633 | 634 | dir = filepath.Join(dir, "Downloads") 635 | err = os.MkdirAll(dir, 0755) 636 | if err != nil { 637 | return os.TempDir() 638 | } 639 | return dir 640 | } 641 | 642 | func getDownloadFilePath(fileName string) string { 643 | dir := getDownloadDirectory() 644 | 645 | p := filepath.Join(dir, fileName) 646 | if _, err := os.Stat(p); err != nil { 647 | return p 648 | } 649 | 650 | ext := filepath.Ext(fileName) 651 | base := strings.TrimSuffix(fileName, ext) 652 | 653 | num := 1 654 | if strings.HasSuffix(base, ")") { 655 | if i := strings.LastIndex(base, " ("); i > 0 { 656 | if n, err := strconv.Atoi(base[i+2:]); err == nil { 657 | base = base[:i] 658 | num = n 659 | } 660 | } 661 | } 662 | 663 | for { 664 | p = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, num, ext)) 665 | if _, err := os.Stat(p); err != nil { 666 | return p 667 | } 668 | num++ 669 | } 670 | } 671 | 672 | func (h *KintoneHandlers) DownloadAttachmentFile(ctx context.Context, params json.RawMessage) ([]Content, error) { 673 | var req struct { 674 | FileKey string `json:"fileKey"` 675 | } 676 | if err := UnmarshalParams(params, &req); err != nil { 677 | return nil, err 678 | } 679 | if req.FileKey == "" { 680 | return nil, jsonrpc2.Error{ 681 | Code: jsonrpc2.InvalidParamsCode, 682 | Message: "Argument 'fileKey' is required", 683 | } 684 | } 685 | 686 | httpRes, err := h.SendHTTP(ctx, "GET", "/k/v1/file.json", Query{"fileKey": req.FileKey}, nil, "") 687 | if err != nil { 688 | return nil, err 689 | } 690 | defer httpRes.Body.Close() 691 | 692 | contentType := httpRes.Header.Get("Content-Type") 693 | if contentType == "" { 694 | contentType = "application/octet-stream" 695 | } 696 | 697 | var fileName string 698 | 699 | _, ps, err := mime.ParseMediaType(httpRes.Header.Get("Content-Disposition")) 700 | if err == nil { 701 | fileName = ps["filename"] 702 | } 703 | 704 | fileName, err = new(mime.WordDecoder).DecodeHeader(fileName) 705 | if err != nil { 706 | fmt.Fprintf(os.Stderr, "Failed to decode filename: %v\n", err) 707 | fileName = "" 708 | } 709 | 710 | if fileName == "" { 711 | fileName = req.FileKey 712 | 713 | ext, err := mime.ExtensionsByType(contentType) 714 | if err == nil && len(ext) > 0 { 715 | fileName += ext[0] 716 | } 717 | } 718 | 719 | outPath := getDownloadFilePath(fileName) 720 | outFile, err := os.Create(outPath) 721 | if err != nil { 722 | return nil, jsonrpc2.Error{ 723 | Code: jsonrpc2.InternalErrorCode, 724 | Message: fmt.Sprintf("Failed to create file for attachment: %v", err), 725 | Data: JsonMap{"filePath": outPath}, 726 | } 727 | } 728 | defer outFile.Close() 729 | 730 | var w io.Writer = outFile 731 | var buf *bytes.Buffer 732 | if strings.HasPrefix(contentType, "text/") || strings.HasPrefix(contentType, "image/") { 733 | buf = new(bytes.Buffer) 734 | w = io.MultiWriter(outFile, buf) 735 | } 736 | 737 | size, err := io.Copy(w, httpRes.Body) 738 | if err != nil { 739 | outFile.Close() 740 | os.Remove(outPath) 741 | return nil, jsonrpc2.Error{ 742 | Code: jsonrpc2.InternalErrorCode, 743 | Message: fmt.Sprintf("Failed to save attachment file: %s: %v", outPath, err), 744 | } 745 | } 746 | 747 | res, err := JSONContent(JsonMap{ 748 | "success": true, 749 | "filePath": outPath, 750 | "size": size, 751 | }) 752 | if err != nil { 753 | return nil, err 754 | } 755 | 756 | if strings.HasPrefix(contentType, "text/") { 757 | res = append(res, Content{Type: "text", Text: buf.String()}) 758 | } else if strings.HasPrefix(contentType, "image/") { 759 | b64 := base64.StdEncoding.EncodeToString(buf.Bytes()) 760 | res = append(res, Content{ 761 | Type: "image", 762 | Data: b64, 763 | MimeType: contentType, 764 | }) 765 | } 766 | 767 | return res, nil 768 | } 769 | 770 | func (h *KintoneHandlers) UploadAttachmentFile(ctx context.Context, params json.RawMessage) ([]Content, error) { 771 | var req struct { 772 | Path *string `json:"path"` 773 | Name string `json:"name"` 774 | Content *string `json:"content"` 775 | Base64 bool `json:"base64"` 776 | } 777 | if err := UnmarshalParams(params, &req); err != nil { 778 | return nil, err 779 | } 780 | 781 | if req.Path == nil && req.Content == nil { 782 | return nil, jsonrpc2.Error{ 783 | Code: jsonrpc2.InvalidParamsCode, 784 | Message: "Arguments 'path' or 'content' is required", 785 | } 786 | } 787 | if req.Path != nil && req.Content != nil { 788 | return nil, jsonrpc2.Error{ 789 | Code: jsonrpc2.InvalidParamsCode, 790 | Message: "Arguments 'path' and 'content' are mutually exclusive", 791 | } 792 | } 793 | 794 | var filename string 795 | if req.Path != nil { 796 | filename = filepath.Base(*req.Path) 797 | } else { 798 | filename = req.Name 799 | if filename == "" { 800 | filename = "file" 801 | 802 | ext, err := mime.ExtensionsByType(mime.TypeByExtension(filepath.Ext(req.Name))) 803 | if err == nil && len(ext) > 0 { 804 | filename += ext[0] 805 | } 806 | } 807 | } 808 | 809 | var body bytes.Buffer 810 | mw := multipart.NewWriter(&body) 811 | part, err := mw.CreateFormFile("file", filename) 812 | if err != nil { 813 | return nil, jsonrpc2.Error{ 814 | Code: jsonrpc2.InternalErrorCode, 815 | Message: fmt.Sprintf("Failed to prepare request: %v", err), 816 | } 817 | } 818 | 819 | if req.Path != nil { 820 | r, err := os.Open(*req.Path) 821 | if err != nil { 822 | return nil, jsonrpc2.Error{ 823 | Code: jsonrpc2.InternalErrorCode, 824 | Message: fmt.Sprintf("Failed to open file: %v", err), 825 | } 826 | } 827 | defer r.Close() 828 | 829 | if _, err := io.Copy(part, r); err != nil { 830 | return nil, jsonrpc2.Error{ 831 | Code: jsonrpc2.InternalErrorCode, 832 | Message: fmt.Sprintf("Failed to read file content: %v", err), 833 | } 834 | } 835 | } else if req.Base64 { 836 | r := base64.NewDecoder(base64.StdEncoding, strings.NewReader(*req.Content)) 837 | if _, err := io.Copy(part, r); err != nil { 838 | return nil, jsonrpc2.Error{ 839 | Code: jsonrpc2.InternalErrorCode, 840 | Message: fmt.Sprintf("Failed to read file content: %v", err), 841 | } 842 | } 843 | } else { 844 | if _, err := part.Write([]byte(*req.Content)); err != nil { 845 | return nil, jsonrpc2.Error{ 846 | Code: jsonrpc2.InternalErrorCode, 847 | Message: fmt.Sprintf("Failed to read file content: %v", err), 848 | } 849 | } 850 | } 851 | 852 | if err := mw.Close(); err != nil { 853 | return nil, jsonrpc2.Error{ 854 | Code: jsonrpc2.InternalErrorCode, 855 | Message: fmt.Sprintf("Failed to finalize request: %v", err), 856 | } 857 | } 858 | 859 | var res struct { 860 | FileKey string `json:"fileKey"` 861 | } 862 | if err := h.FetchHTTPWithReader(ctx, "POST", "/k/v1/file.json", nil, &body, mw.FormDataContentType(), &res); err != nil { 863 | return nil, err 864 | } 865 | 866 | return JSONContent(JsonMap{ 867 | "success": true, 868 | "fileKey": res.FileKey, 869 | }) 870 | } 871 | 872 | func (h *KintoneHandlers) ReadRecordComments(ctx context.Context, params json.RawMessage) ([]Content, error) { 873 | var req struct { 874 | AppID string `json:"appID"` 875 | RecordID string `json:"recordID"` 876 | Order string `json:"order"` 877 | Offset int `json:"offset"` 878 | Limit *int `json:"limit"` 879 | } 880 | if err := UnmarshalParams(params, &req); err != nil { 881 | return nil, err 882 | } 883 | 884 | if req.AppID == "" || req.RecordID == "" { 885 | return nil, jsonrpc2.Error{ 886 | Code: jsonrpc2.InvalidParamsCode, 887 | Message: "Arguments 'appID' and 'recordID' are required", 888 | } 889 | } 890 | 891 | if req.Order == "" { 892 | req.Order = "desc" 893 | } else if req.Order != "asc" && req.Order != "desc" { 894 | return nil, jsonrpc2.Error{ 895 | Code: jsonrpc2.InvalidParamsCode, 896 | Message: "Order must be 'asc' or 'desc'", 897 | } 898 | } 899 | 900 | if req.Offset < 0 { 901 | return nil, jsonrpc2.Error{ 902 | Code: jsonrpc2.InvalidParamsCode, 903 | Message: "Offset must be greater than or equal to 0", 904 | } 905 | } 906 | 907 | if req.Limit == nil { 908 | limit := 10 909 | req.Limit = &limit 910 | } else if *req.Limit < 0 || *req.Limit > 10 { 911 | return nil, jsonrpc2.Error{ 912 | Code: jsonrpc2.InvalidParamsCode, 913 | Message: "Limit must be between 1 and 10", 914 | } 915 | } 916 | 917 | if err := h.checkPermissions(req.AppID); err != nil { 918 | return nil, err 919 | } 920 | 921 | httpReq := JsonMap{ 922 | "app": req.AppID, 923 | "record": req.RecordID, 924 | "order": req.Order, 925 | "offset": req.Offset, 926 | "limit": *req.Limit, 927 | } 928 | var httpRes struct { 929 | Comments []JsonMap `json:"comments"` 930 | Older bool `json:"older"` 931 | Newer bool `json:"newer"` 932 | } 933 | if err := h.FetchHTTPWithJSON(ctx, "GET", "/k/v1/record/comments.json", nil, httpReq, &httpRes); err != nil { 934 | return nil, err 935 | } 936 | 937 | return JSONContent(JsonMap{ 938 | "comments": httpRes.Comments, 939 | "existsOlderComments": httpRes.Older, 940 | "existsNewerComments": httpRes.Newer, 941 | }) 942 | } 943 | 944 | func (h *KintoneHandlers) CreateRecordComment(ctx context.Context, params json.RawMessage) ([]Content, error) { 945 | var req struct { 946 | AppID string `json:"appID"` 947 | RecordID string `json:"recordID"` 948 | Comment struct { 949 | Text string `json:"text"` 950 | Mentions []struct { 951 | Code string `json:"code"` 952 | Type string `json:"type"` 953 | } `json:"mentions"` 954 | } `json:"comment"` 955 | } 956 | if err := UnmarshalParams(params, &req); err != nil { 957 | return nil, err 958 | } 959 | 960 | if req.AppID == "" || req.RecordID == "" || req.Comment.Text == "" { 961 | return nil, jsonrpc2.Error{ 962 | Code: jsonrpc2.InvalidParamsCode, 963 | Message: "Arguments 'appID', 'recordID', and 'comment.text' are required", 964 | } 965 | } 966 | 967 | for i, m := range req.Comment.Mentions { 968 | if m.Code == "" { 969 | return nil, jsonrpc2.Error{ 970 | Code: jsonrpc2.InvalidParamsCode, 971 | Message: "Mention code is required", 972 | } 973 | } 974 | if m.Type == "" { 975 | req.Comment.Mentions[i].Type = "USER" 976 | } else if m.Type != "USER" && m.Type != "GROUP" && m.Type != "ORGANIZATION" { 977 | return nil, jsonrpc2.Error{ 978 | Code: jsonrpc2.InvalidParamsCode, 979 | Message: "Mention type must be 'USER', 'GROUP', or 'ORGANIZATION'", 980 | } 981 | } 982 | } 983 | 984 | if err := h.checkPermissions(req.AppID); err != nil { 985 | return nil, err 986 | } 987 | 988 | httpReq := JsonMap{ 989 | "app": req.AppID, 990 | "record": req.RecordID, 991 | "comment": req.Comment, 992 | } 993 | if err := h.FetchHTTPWithJSON(ctx, "POST", "/k/v1/record/comment.json", nil, httpReq, nil); err != nil { 994 | return nil, err 995 | } 996 | 997 | return JSONContent(JsonMap{ 998 | "success": true, 999 | }) 1000 | } 1001 | 1002 | func (h *KintoneHandlers) UpdateProcessManagementAssignee(ctx context.Context, params json.RawMessage) ([]Content, error) { 1003 | var req struct { 1004 | AppID string `json:"appID"` 1005 | RecordID string `json:"recordID"` 1006 | Assignees []string `json:"assignees"` 1007 | } 1008 | if err := UnmarshalParams(params, &req); err != nil { 1009 | return nil, err 1010 | } 1011 | if req.AppID == "" || req.RecordID == "" { 1012 | return nil, jsonrpc2.Error{ 1013 | Code: jsonrpc2.InvalidParamsCode, 1014 | Message: "Arguments 'appID' and 'recordID' are required", 1015 | } 1016 | } 1017 | 1018 | httpReq := JsonMap{ 1019 | "app": req.AppID, 1020 | "id": req.RecordID, 1021 | "assignees": req.Assignees, 1022 | } 1023 | if err := h.FetchHTTPWithJSON(ctx, "PUT", "/k/v1/record/assignees.json", nil, httpReq, nil); err != nil { 1024 | return nil, err 1025 | } 1026 | 1027 | return JSONContent(JsonMap{ 1028 | "success": true, 1029 | }) 1030 | } 1031 | 1032 | func (h *KintoneHandlers) ExecuteProcessManagementAction(ctx context.Context, params json.RawMessage) ([]Content, error) { 1033 | var req struct { 1034 | AppID string `json:"appID"` 1035 | RecordID string `json:"recordID"` 1036 | Action string `json:"action"` 1037 | Assignee *string `json:"assignee"` 1038 | } 1039 | if err := UnmarshalParams(params, &req); err != nil { 1040 | return nil, err 1041 | } 1042 | if req.AppID == "" || req.RecordID == "" || req.Action == "" { 1043 | return nil, jsonrpc2.Error{ 1044 | Code: jsonrpc2.InvalidParamsCode, 1045 | Message: "Arguments 'appID', 'recordID', and 'action' are required", 1046 | } 1047 | } 1048 | 1049 | httpReq := JsonMap{ 1050 | "app": req.AppID, 1051 | "id": req.RecordID, 1052 | "action": req.Action, 1053 | } 1054 | if req.Assignee != nil { 1055 | httpReq["assignee"] = *req.Assignee 1056 | } 1057 | if err := h.FetchHTTPWithJSON(ctx, "PUT", "/k/v1/record/status.json", nil, httpReq, nil); err != nil { 1058 | return nil, err 1059 | } 1060 | 1061 | return JSONContent(JsonMap{ 1062 | "success": true, 1063 | }) 1064 | } 1065 | 1066 | func Getenv(key, defaultValue string) string { 1067 | if v := os.Getenv(key); v != "" { 1068 | return v 1069 | } 1070 | return defaultValue 1071 | } 1072 | 1073 | func GetenvList(key string) []string { 1074 | if v := os.Getenv(key); v != "" { 1075 | raw := strings.Split(v, ",") 1076 | ss := make([]string, 0, len(raw)) 1077 | for _, s := range raw { 1078 | if s != "" { 1079 | ss = append(ss, strings.TrimSpace(s)) 1080 | } 1081 | } 1082 | return ss 1083 | } 1084 | return nil 1085 | } 1086 | 1087 | type MergedReadWriter struct { 1088 | r io.Reader 1089 | w io.Writer 1090 | } 1091 | 1092 | func (rw *MergedReadWriter) Read(p []byte) (int, error) { 1093 | return rw.r.Read(p) 1094 | } 1095 | 1096 | func (rw *MergedReadWriter) Write(p []byte) (int, error) { 1097 | return rw.w.Write(p) 1098 | } 1099 | 1100 | func main() { 1101 | handlers, err := NewKintoneHandlersFromEnv() 1102 | if err != nil { 1103 | fmt.Fprintf(os.Stderr, "%s\n", err) 1104 | os.Exit(1) 1105 | } 1106 | 1107 | server := jsonrpc2.NewServer() 1108 | server.On("initialize", jsonrpc2.Call(handlers.InitializeHandler)) 1109 | server.On("notifications/initialized", jsonrpc2.Notify(func(ctx context.Context, params any) error { 1110 | return nil 1111 | })) 1112 | server.On("ping", jsonrpc2.Call(func(ctx context.Context, params any) (struct{}, error) { 1113 | return struct{}{}, nil 1114 | })) 1115 | server.On("tools/list", jsonrpc2.Call(handlers.ToolsList)) 1116 | server.On("tools/call", jsonrpc2.Call(handlers.ToolsCall)) 1117 | 1118 | fmt.Fprintf(os.Stderr, "kintone server is running on stdio!\n") 1119 | 1120 | server.ServeForOne(&MergedReadWriter{r: os.Stdin, w: os.Stdout}) 1121 | } 1122 | ```