├── .github ├── CODEOWNERS ├── workflows │ ├── lint.yml │ ├── tools-list.yml │ ├── scorecard.yml │ └── release.yml └── pull_request_template.md ├── hubmcp.gif ├── .gitignore ├── .prettierrc.json ├── .prettierignore ├── eslint.config.mjs ├── tools.txt ├── src ├── scout │ ├── genql │ │ ├── runtime │ │ │ ├── index.ts │ │ │ ├── error.ts │ │ │ ├── types.ts │ │ │ ├── createClient.ts │ │ │ ├── fetcher.ts │ │ │ ├── typeSelection.ts │ │ │ ├── linkTypeMap.ts │ │ │ ├── generateGraphqlOperation.ts │ │ │ └── batcher.ts │ │ └── index.ts │ └── client.ts ├── types.ts ├── logger.ts ├── index.ts ├── scripts │ └── toolsList.ts ├── server.ts ├── scout.ts ├── accounts.ts ├── asset.ts ├── search.ts └── repos.ts ├── tsconfig.json ├── Dockerfile ├── SECURITY.md ├── package.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── CONTRIBUTING.md └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @docker/registry -------------------------------------------------------------------------------- /hubmcp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docker/hub-mcp/main/hubmcp.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .vscode/ 4 | .env 5 | logs/ 6 | gordon-mcp.yml 7 | /.idea/ 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "trailingComma": "es5", 6 | "printWidth": 100, 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | src/scout/genql 4 | .gitignore 5 | .prettierrc.json 6 | .prettierignore 7 | .gitignore 8 | .github 9 | .vscode 10 | package-lock.json 11 | package.json 12 | tsconfig.json 13 | Dockerfile -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | tseslint.configs.recommended, 9 | { 10 | ignores: ["node_modules/**", "dist/**", "src/scout/genql/**"], 11 | } 12 | ); 13 | -------------------------------------------------------------------------------- /tools.txt: -------------------------------------------------------------------------------- 1 | - name: listRepositoriesByNamespace 2 | - name: createRepository 3 | - name: getRepositoryInfo 4 | - name: updateRepositoryInfo 5 | - name: checkRepository 6 | - name: listRepositoryTags 7 | - name: getRepositoryTag 8 | - name: checkRepositoryTag 9 | - name: listNamespaces 10 | - name: getPersonalNamespace 11 | - name: listAllNamespacesMemberOf 12 | - name: search 13 | - name: dockerHardenedImages -------------------------------------------------------------------------------- /src/scout/genql/runtime/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | export { createClient } from './createClient' 3 | export type { ClientOptions } from './createClient' 4 | export type { FieldsSelection } from './typeSelection' 5 | export { generateGraphqlOperation } from './generateGraphqlOperation' 6 | export type { GraphqlOperation } from './generateGraphqlOperation' 7 | export { linkTypeMap } from './linkTypeMap' 8 | // export { Observable } from 'zen-observable-ts' 9 | export { createFetcher } from './fetcher' 10 | export { GenqlError } from './error' 11 | export const everything = { 12 | __scalar: true, 13 | } 14 | -------------------------------------------------------------------------------- /src/scout/genql/runtime/error.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | export class GenqlError extends Error { 3 | errors: Array = [] 4 | /** 5 | * Partial data returned by the server 6 | */ 7 | data?: any 8 | constructor(errors: any[], data: any) { 9 | let message = Array.isArray(errors) 10 | ? errors.map((x) => x?.message || '').join('\n') 11 | : '' 12 | if (!message) { 13 | message = 'GraphQL error' 14 | } 15 | super(message) 16 | this.errors = errors 17 | this.data = data 18 | } 19 | } 20 | 21 | interface GraphqlError { 22 | message: string 23 | locations?: Array<{ 24 | line: number 25 | column: number 26 | }> 27 | path?: string[] 28 | extensions?: Record 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "lib": ["ES2020", "DOM"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "sourceMap": true, 15 | "removeComments": false, 16 | "noImplicitAny": true, 17 | "moduleResolution": "node", 18 | "allowSyntheticDefaultImports": true, 19 | "experimentalDecorators": true, 20 | "emitDecoratorMetadata": true, 21 | "resolveJsonModule": true, 22 | "typeRoots": ["./node_modules/@types"] 23 | }, 24 | "include": ["src/**/*"], 25 | "exclude": ["node_modules", "dist", "**/*.test.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | # Replace pull_request with pull_request_target if you 8 | # plan to use this action with forks, see the Limitations section 9 | pull_request: 10 | branches: 11 | - "**" 12 | 13 | jobs: 14 | run-linters: 15 | name: Run linters 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Check out Git repository 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 21 | 22 | - name: Set up Node.js 23 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0 24 | with: 25 | node-version: 22 26 | 27 | - name: Install Node.js dependencies 28 | run: npm ci 29 | 30 | - name: Run linters 31 | run: npm run lint 32 | 33 | - name: Run Formatting 34 | run: npm run format:check 35 | -------------------------------------------------------------------------------- /.github/workflows/tools-list.yml: -------------------------------------------------------------------------------- 1 | name: Tools List 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | # Replace pull_request with pull_request_target if you 8 | # plan to use this action with forks, see the Limitations section 9 | pull_request: 10 | branches: 11 | - "**" 12 | 13 | jobs: 14 | run-tools-list: 15 | name: Run tools list 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Check out Git repository 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 21 | 22 | - name: Set up Node.js 23 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0 24 | with: 25 | node-version: 22 26 | 27 | - name: Install Node.js dependencies 28 | run: npm ci 29 | 30 | - name: Build 31 | run: npm run build 32 | 33 | - name: Check tools list 34 | run: npm run list-tools:check 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { z } from 'zod'; 18 | 19 | // all items in the types are optional and nullable because structured content is always evaluated even when an error occurs. 20 | // See https://github.com/modelcontextprotocol/typescript-sdk/issues/654 21 | export function createPaginatedResponseSchema(itemSchema: ItemType) { 22 | return z.object({ 23 | count: z.number().optional().nullable(), 24 | next: z.string().nullable().optional(), 25 | previous: z.string().nullable().optional(), 26 | results: z.array(itemSchema).optional().nullable(), 27 | error: z.string().optional().nullable(), 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | ## Tool Details 6 | 7 | - Tool: 8 | - Changes to: 9 | 10 | ## Motivation and Context 11 | 12 | 13 | ## How Has This Been Tested? 14 | 15 | 16 | ## Breaking Changes 17 | 18 | 19 | ## Types of changes 20 | 21 | - [ ] Bug fix (non-breaking change which fixes an issue) 22 | - [ ] New feature (non-breaking change which adds functionality) 23 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 24 | - [ ] Documentation update 25 | 26 | ## Checklist 27 | 28 | - [ ] I have read the [MCP Protocol Documentation](https://modelcontextprotocol.io) 29 | - [ ] My changes follows MCP security best practices 30 | - [ ] I have updated the server README accordingly 31 | - [ ] I have tested this with an LLM client 32 | - [ ] My code follows the repository's style guidelines 33 | - [ ] I have added appropriate error handling 34 | - [ ] I have documented all environment variables and configuration options 35 | 36 | ## Additional context 37 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Docker Hub MCP Server authors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | FROM node:current-alpine3.22 AS builder 17 | WORKDIR /app 18 | 19 | COPY package.json . 20 | COPY package-lock.json . 21 | COPY tsconfig.json . 22 | 23 | # Refresh the lock file to be sure we include Linux-only packages that might not 24 | # be in the existing package-lock.json. 25 | RUN npm install --package-lock-only \ 26 | && npm ci 27 | 28 | COPY src/ ./src/ 29 | 30 | RUN npm run build 31 | 32 | FROM node:current-alpine3.22 33 | # Create app directory 34 | WORKDIR /app 35 | 36 | # Create a non-root user 37 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup 38 | 39 | # Copy built files from builder stage 40 | COPY --from=builder /app/package*.json ./ 41 | COPY --from=builder /app/dist/ ./dist/ 42 | 43 | # Install production dependencies only 44 | RUN npm ci --omit=dev && npm cache clean --force 45 | 46 | # Set proper permissions 47 | RUN chown -R appuser:appgroup /app 48 | 49 | # Switch to non-root user 50 | USER appuser 51 | 52 | # Set environment variables 53 | ENV NODE_ENV=production 54 | 55 | # Command to run the application 56 | ENTRYPOINT ["node", "dist/index.js"] 57 | -------------------------------------------------------------------------------- /src/scout/genql/runtime/types.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | export interface ExecutionResult { 4 | errors?: Array 5 | data?: TData | null 6 | } 7 | 8 | export interface ArgMap { 9 | [arg: string]: [keyType, string] | [keyType] | undefined 10 | } 11 | 12 | export type CompressedField = [ 13 | type: keyType, 14 | args?: ArgMap, 15 | ] 16 | 17 | export interface CompressedFieldMap { 18 | [field: string]: CompressedField | undefined 19 | } 20 | 21 | export type CompressedType = CompressedFieldMap 22 | 23 | export interface CompressedTypeMap { 24 | scalars: Array 25 | types: { 26 | [type: string]: CompressedType | undefined 27 | } 28 | } 29 | 30 | // normal types 31 | export type Field = { 32 | type: keyType 33 | args?: ArgMap 34 | } 35 | 36 | export interface FieldMap { 37 | [field: string]: Field | undefined 38 | } 39 | 40 | export type Type = FieldMap 41 | 42 | export interface TypeMap { 43 | scalars: Array 44 | types: { 45 | [type: string]: Type | undefined 46 | } 47 | } 48 | 49 | export interface LinkedArgMap { 50 | [arg: string]: [LinkedType, string] | undefined 51 | } 52 | export interface LinkedField { 53 | type: LinkedType 54 | args?: LinkedArgMap 55 | } 56 | 57 | export interface LinkedFieldMap { 58 | [field: string]: LinkedField | undefined 59 | } 60 | 61 | export interface LinkedType { 62 | name: string 63 | fields?: LinkedFieldMap 64 | scalar?: string[] 65 | } 66 | 67 | export interface LinkedTypeMap { 68 | [type: string]: LinkedType | undefined 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | name: Scorecard supply-chain security 2 | on: 3 | # For Branch-Protection check. Only the default branch is supported. See 4 | branch_protection_rule: 5 | schedule: 6 | - cron: "18 9 * * 4" 7 | push: 8 | branches: ["main"] 9 | 10 | jobs: 11 | analysis: 12 | name: Scorecard analysis 13 | runs-on: ubuntu-latest 14 | # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. 15 | if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' 16 | permissions: 17 | # Needed to upload the results to code-scanning dashboard. 18 | security-events: write 19 | # Needed to publish results and get a badge (see publish_results below). 20 | id-token: write 21 | actions: read 22 | attestations: read 23 | checks: read 24 | contents: read 25 | deployments: read 26 | issues: read 27 | discussions: read 28 | packages: read 29 | pages: read 30 | pull-requests: read 31 | statuses: read 32 | 33 | steps: 34 | - name: "Checkout code" 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | with: 37 | persist-credentials: false 38 | 39 | - name: "Run analysis" 40 | uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 41 | with: 42 | results_file: results.sarif 43 | results_format: sarif 44 | # Publish results to include the Scorecard badge. 45 | # - See https://github.com/ossf/scorecard-action#publishing-results. 46 | publish_results: true 47 | 48 | - name: "Upload to code-scanning" 49 | uses: github/codeql-action/upload-sarif@v3 50 | with: 51 | sarif_file: results.sarif 52 | -------------------------------------------------------------------------------- /src/scout/genql/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | QueryGenqlSelection, 3 | Query, 4 | MutationGenqlSelection, 5 | Mutation, 6 | } from './schema' 7 | import { 8 | linkTypeMap, 9 | createClient as createClientOriginal, 10 | generateGraphqlOperation, 11 | type FieldsSelection, 12 | type GraphqlOperation, 13 | type ClientOptions, 14 | GenqlError, 15 | } from './runtime' 16 | export type { FieldsSelection } from './runtime' 17 | export { GenqlError } 18 | 19 | import types from './types' 20 | export * from './schema' 21 | const typeMap = linkTypeMap(types as any) 22 | 23 | export interface Client { 24 | query( 25 | request: R & { __name?: string }, 26 | ): Promise> 27 | 28 | mutation( 29 | request: R & { __name?: string }, 30 | ): Promise> 31 | } 32 | 33 | export const createClient = function (options?: ClientOptions): Client { 34 | return createClientOriginal({ 35 | url: 'https://api.scout.docker.com/v1/graphql', 36 | 37 | ...options, 38 | queryRoot: typeMap.Query!, 39 | mutationRoot: typeMap.Mutation!, 40 | subscriptionRoot: typeMap.Subscription!, 41 | }) as any 42 | } 43 | 44 | export const everything = { 45 | __scalar: true, 46 | } 47 | 48 | export type QueryResult = FieldsSelection< 49 | Query, 50 | fields 51 | > 52 | export const generateQueryOp: ( 53 | fields: QueryGenqlSelection & { __name?: string }, 54 | ) => GraphqlOperation = function (fields) { 55 | return generateGraphqlOperation('query', typeMap.Query!, fields as any) 56 | } 57 | 58 | export type MutationResult = 59 | FieldsSelection 60 | export const generateMutationOp: ( 61 | fields: MutationGenqlSelection & { __name?: string }, 62 | ) => GraphqlOperation = function (fields) { 63 | return generateGraphqlOperation('mutation', typeMap.Mutation!, fields as any) 64 | } 65 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | The maintainers of the Docker Hub MCP server take security seriously. 4 | If you discover a security issue, please bring it to their attention right away! 5 | 6 | ## Reporting a Vulnerability 7 | 8 | Please **DO NOT** file a public issue, instead send your report privately 9 | to [security@docker.com](mailto:security@docker.com). 10 | 11 | Reporter(s) can expect a response within 72 hours, acknowledging the issue was 12 | received. 13 | 14 | ## Review Process 15 | 16 | After receiving the report, an initial triage and technical analysis is 17 | performed to confirm the report and determine its scope. We may request 18 | additional information in this stage of the process. 19 | 20 | Once a reviewer has confirmed the relevance of the report, a draft security 21 | advisory will be created on GitHub. The draft advisory will be used to discuss 22 | the issue with maintainers, the reporter(s), and where applicable, other 23 | affected parties under embargo. 24 | 25 | If the vulnerability is accepted, a timeline for developing a patch, public 26 | disclosure, and patch release will be determined. If there is an embargo period 27 | on public disclosure before the patch release, the reporter(s) are expected to 28 | participate in the discussion of the timeline and abide by agreed upon dates 29 | for public disclosure. 30 | 31 | ## Accreditation 32 | 33 | Security reports are greatly appreciated and we will publicly thank you, 34 | although we will keep your name confidential if you request it. We also like to 35 | send gifts - if you're into swag, make sure to let us know. We do not currently 36 | offer a paid security bounty program at this time. 37 | 38 | ## Further Information 39 | 40 | Should anything in this document be unclear or if you are looking for additional 41 | information about how Docker reviews and responds to security vulnerabilities, 42 | please take a look at Docker's 43 | [Vulnerability Disclosure Policy](https://www.docker.com/trust/vulnerability-disclosure-policy/). -------------------------------------------------------------------------------- /src/scout/genql/runtime/createClient.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import { type BatchOptions, createFetcher } from './fetcher' 4 | import type { ExecutionResult, LinkedType } from './types' 5 | import { 6 | generateGraphqlOperation, 7 | type GraphqlOperation, 8 | } from './generateGraphqlOperation' 9 | 10 | export type Headers = 11 | | HeadersInit 12 | | (() => HeadersInit) 13 | | (() => Promise) 14 | 15 | export type BaseFetcher = ( 16 | operation: GraphqlOperation | GraphqlOperation[], 17 | ) => Promise 18 | 19 | export type ClientOptions = Omit & { 20 | url?: string 21 | batch?: BatchOptions | boolean 22 | fetcher?: BaseFetcher 23 | fetch?: Function 24 | headers?: Headers 25 | } 26 | 27 | export const createClient = ({ 28 | queryRoot, 29 | mutationRoot, 30 | subscriptionRoot, 31 | ...options 32 | }: ClientOptions & { 33 | queryRoot?: LinkedType 34 | mutationRoot?: LinkedType 35 | subscriptionRoot?: LinkedType 36 | }) => { 37 | const fetcher = createFetcher(options) 38 | const client: { 39 | query?: Function 40 | mutation?: Function 41 | } = {} 42 | 43 | if (queryRoot) { 44 | client.query = (request: any) => { 45 | if (!queryRoot) throw new Error('queryRoot argument is missing') 46 | 47 | const resultPromise = fetcher( 48 | generateGraphqlOperation('query', queryRoot, request), 49 | ) 50 | 51 | return resultPromise 52 | } 53 | } 54 | if (mutationRoot) { 55 | client.mutation = (request: any) => { 56 | if (!mutationRoot) 57 | throw new Error('mutationRoot argument is missing') 58 | 59 | const resultPromise = fetcher( 60 | generateGraphqlOperation('mutation', mutationRoot, request), 61 | ) 62 | 63 | return resultPromise 64 | } 65 | } 66 | 67 | return client as any 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockerhub-mcp-server", 3 | "version": "1.0.0", 4 | "description": "MCP Server that dynamically generates tools from OpenAPI specifications", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "node dist/index.js", 9 | "clean": "rm -rf dist", 10 | "lint": "eslint --ext .ts .", 11 | "inspect": "tsc -w & nodemon --watch dist --exec 'npx @modelcontextprotocol/inspector node dist/index.js'", 12 | "genql.scout": "genql --endpoint https://api.scout.docker.com/v1/graphql --output ./src/scout/genql", 13 | "format:fix": "prettier --write \"**/*.+(ts|json)\"", 14 | "format:check": "prettier --check \"**/*.+(ts|json)\"", 15 | "format": "npm run format:fix", 16 | "list-tools:check": "npm run build && node dist/scripts/toolsList.js check-tools-list", 17 | "list-tools:update": "npm run build && node dist/scripts/toolsList.js update-tools-list", 18 | "list-tools": "npm run list-tools:check && npm run list-tools:update" 19 | }, 20 | "keywords": [ 21 | "mcp", 22 | "openapi", 23 | "swagger", 24 | "api", 25 | "claude" 26 | ], 27 | "dependencies": { 28 | "@apidevtools/swagger-parser": "^10.1.0", 29 | "@genql/cli": "^6.3.3", 30 | "@modelcontextprotocol/sdk": "1.24.0", 31 | "@modelcontextprotocol/specification": "github:modelcontextprotocol/specification", 32 | "express": "^5.1.0", 33 | "jwt-decode": "^4.0.0", 34 | "winston": "^3.17.0", 35 | "zod": "^3.25.67" 36 | }, 37 | "devDependencies": { 38 | "@types/express": "^5.0.3", 39 | "@types/lodash": "^4.17.19", 40 | "@types/node": "^20.10.0", 41 | "commander": "^14.0.0", 42 | "esbuild": "^0.25.5", 43 | "eslint": "^9.29.0", 44 | "glob": "11.1.0", 45 | "i": "^0.3.7", 46 | "lodash": "^4.17.21", 47 | "nodemon": "^3.1.10", 48 | "npm": "^11.4.2", 49 | "prettier": "^3.6.1", 50 | "tsx": "^4.20.1", 51 | "typescript": "^5.3.0", 52 | "typescript-eslint": "^8.35.0" 53 | }, 54 | "engines": { 55 | "node": ">=22" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import path from 'path'; 18 | import winston, { format } from 'winston'; 19 | const logsDir = parseLogsDir(process.argv.slice(2)); 20 | 21 | function parseLogsDir(args: string[]): string | undefined { 22 | const logsDirArg = args.find((arg) => arg.startsWith('--logs-dir='))?.split('=')[1]; 23 | if (!logsDirArg) { 24 | if (process.env.NODE_ENV === 'production') { 25 | return '/app/logs'; 26 | } 27 | console.warn('logs dir unspecified'); 28 | return undefined; 29 | } 30 | 31 | return logsDirArg; 32 | } 33 | 34 | export const logger = winston.createLogger({ 35 | level: 'info', 36 | format: format.combine( 37 | format.timestamp({ 38 | format: 'YYYY-MM-DD HH:mm:ss', 39 | }), 40 | format.errors({ stack: true }), 41 | format.splat(), 42 | format.json() 43 | ), 44 | defaultMeta: { service: 'dockerhub-mcp-server' }, 45 | transports: logsDir 46 | ? [ 47 | // 48 | // - Write all logs with importance level of `error` or higher to `error.log` 49 | // (i.e., error, fatal, but not other levels) 50 | // 51 | new winston.transports.File({ 52 | filename: path.join(logsDir, 'error.log'), 53 | level: 'warn', 54 | }), 55 | // 56 | // - Write all logs with importance level of `info` or higher to `combined.log` 57 | // (i.e., fatal, error, warn, and info, but not trace) 58 | // 59 | new winston.transports.File({ 60 | filename: path.join(logsDir, 'mcp.log'), 61 | level: 'info', 62 | }), 63 | ] 64 | : [], 65 | }); 66 | 67 | // 68 | // If we're not in production then log to the `console` with the format: 69 | // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` 70 | 71 | if (process.env.NODE_ENV !== 'production') { 72 | logger.add( 73 | new winston.transports.Console({ 74 | format: winston.format.simple(), 75 | log: (info) => { 76 | console.error(info.message); 77 | }, 78 | }) 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | import { logger } from './logger'; 17 | import { HubMCPServer } from './server'; 18 | 19 | const DEFAULT_PORT = 3000; 20 | const STDIO_OPTION = 'stdio'; 21 | 22 | function parseTransportFlag(args: string[]): string { 23 | const transportArg = args.find((arg) => arg.startsWith('--transport='))?.split('=')[1]; 24 | if (!transportArg) { 25 | logger.info(`transport unspecified, defaulting to ${STDIO_OPTION}`); 26 | return STDIO_OPTION; 27 | } 28 | 29 | return transportArg; 30 | } 31 | 32 | function parseUsernameFlag(args: string[]): string | undefined { 33 | const usernameArg = args.find((arg) => arg.startsWith('--username='))?.split('=')[1]; 34 | if (!usernameArg) { 35 | logger.info('username unspecified'); 36 | return undefined; 37 | } 38 | 39 | return usernameArg; 40 | } 41 | 42 | function parsePortFlag(args: string[]): number { 43 | const portArg = args.find((arg) => arg.startsWith('--port='))?.split('=')[1]; 44 | if (!portArg || portArg.length === 0) { 45 | logger.info(`port unspecified, defaulting to ${DEFAULT_PORT}`); 46 | return DEFAULT_PORT; 47 | } 48 | 49 | const portParsed = parseInt(portArg, 10); 50 | if (isNaN(portParsed)) { 51 | logger.info(`invalid port specified, defaulting to ${DEFAULT_PORT}`); 52 | return DEFAULT_PORT; 53 | } 54 | 55 | return portParsed; 56 | } 57 | 58 | // Main execution 59 | async function main() { 60 | const args = process.argv.slice(2); 61 | logger.info(args.length > 0 ? `provided arguments: ${args}` : 'no arguments provided'); 62 | const transportArg = parseTransportFlag(args); 63 | const port = parsePortFlag(args); 64 | const username = parseUsernameFlag(args); 65 | const patToken = process.env.HUB_PAT_TOKEN; 66 | 67 | const server = new HubMCPServer(username, patToken); 68 | // Start the server 69 | await server.run(port, transportArg); 70 | logger.info('🚀 dockerhub mcp server is running...'); 71 | } 72 | 73 | process.on('unhandledRejection', (error) => { 74 | logger.info(`unhandled rejection: ${error}`); 75 | process.exit(1); 76 | }); 77 | 78 | main().catch((error) => { 79 | logger.info(`failed to start server: ${error}`); 80 | process.exit(1); 81 | }); 82 | 83 | // Handle server shutdown 84 | process.on('SIGINT', async () => { 85 | logger.info('shutting down server...'); 86 | process.exit(0); 87 | }); 88 | -------------------------------------------------------------------------------- /src/scout/genql/runtime/fetcher.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { QueryBatcher } from './batcher' 3 | 4 | import type { ClientOptions } from './createClient' 5 | import type { GraphqlOperation } from './generateGraphqlOperation' 6 | import { GenqlError } from './error' 7 | 8 | export interface Fetcher { 9 | (gql: GraphqlOperation): Promise 10 | } 11 | 12 | export type BatchOptions = { 13 | batchInterval?: number // ms 14 | maxBatchSize?: number 15 | } 16 | 17 | const DEFAULT_BATCH_OPTIONS = { 18 | maxBatchSize: 10, 19 | batchInterval: 40, 20 | } 21 | 22 | export const createFetcher = ({ 23 | url, 24 | headers = {}, 25 | fetcher, 26 | fetch: _fetch, 27 | batch = false, 28 | ...rest 29 | }: ClientOptions): Fetcher => { 30 | if (!url && !fetcher) { 31 | throw new Error('url or fetcher is required') 32 | } 33 | 34 | fetcher = fetcher || (async (body) => { 35 | let headersObject = 36 | typeof headers == 'function' ? await headers() : headers 37 | headersObject = headersObject || {} 38 | if (typeof fetch === 'undefined' && !_fetch) { 39 | throw new Error( 40 | 'Global `fetch` function is not available, pass a fetch polyfill to Genql `createClient`', 41 | ) 42 | } 43 | let fetchImpl = _fetch || fetch 44 | const res = await fetchImpl(url!, { 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | ...headersObject, 48 | }, 49 | method: 'POST', 50 | body: JSON.stringify(body), 51 | ...rest, 52 | }) 53 | if (!res.ok) { 54 | throw new Error(`${res.statusText}: ${await res.text()}`) 55 | } 56 | const json = await res.json() 57 | return json 58 | }) 59 | 60 | if (!batch) { 61 | return async (body) => { 62 | const json = await fetcher!(body) 63 | if (Array.isArray(json)) { 64 | return json.map((json) => { 65 | if (json?.errors?.length) { 66 | throw new GenqlError(json.errors || [], json.data) 67 | } 68 | return json.data 69 | }) 70 | } else { 71 | if (json?.errors?.length) { 72 | throw new GenqlError(json.errors || [], json.data) 73 | } 74 | return json.data 75 | } 76 | } 77 | } 78 | 79 | const batcher = new QueryBatcher( 80 | async (batchedQuery) => { 81 | // console.log(batchedQuery) // [{ query: 'query{user{age}}', variables: {} }, ...] 82 | const json = await fetcher!(batchedQuery) 83 | return json as any 84 | }, 85 | batch === true ? DEFAULT_BATCH_OPTIONS : batch, 86 | ) 87 | 88 | return async ({ query, variables }) => { 89 | const json = await batcher.fetch(query, variables) 90 | if (json?.data) { 91 | return json.data 92 | } 93 | throw new Error( 94 | 'Genql batch fetcher returned unexpected result ' + JSON.stringify(json), 95 | ) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/scout/genql/runtime/typeSelection.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | ////////////////////////////////////////////////// 3 | 4 | // SOME THINGS TO KNOW BEFORE DIVING IN 5 | /* 6 | 0. DST is the request type, SRC is the response type 7 | 8 | 1. FieldsSelection uses an object because currently is impossible to make recursive types 9 | 10 | 2. FieldsSelection is a recursive type that makes a type based on request type and fields 11 | 12 | 3. HandleObject handles object types 13 | 14 | 4. Handle__scalar adds all scalar properties excluding non scalar props 15 | */ 16 | 17 | export type FieldsSelection | undefined, DST> = { 18 | scalar: SRC 19 | union: Handle__isUnion 20 | object: HandleObject 21 | array: SRC extends Nil 22 | ? never 23 | : SRC extends Array 24 | ? Array> 25 | : never 26 | __scalar: Handle__scalar 27 | never: never 28 | }[DST extends Nil 29 | ? 'never' 30 | : DST extends false | 0 31 | ? 'never' 32 | : SRC extends Scalar 33 | ? 'scalar' 34 | : SRC extends any[] 35 | ? 'array' 36 | : SRC extends { __isUnion?: any } 37 | ? 'union' 38 | : DST extends { __scalar?: any } 39 | ? '__scalar' 40 | : DST extends {} 41 | ? 'object' 42 | : 'never'] 43 | 44 | type HandleObject, DST> = DST extends boolean 45 | ? SRC 46 | : SRC extends Nil 47 | ? never 48 | : Pick< 49 | { 50 | // using keyof SRC to maintain ?: relations of SRC type 51 | [Key in keyof SRC]: Key extends keyof DST 52 | ? FieldsSelection> 53 | : SRC[Key] 54 | }, 55 | Exclude 56 | // { 57 | // // remove falsy values 58 | // [Key in keyof DST]: DST[Key] extends false | 0 ? never : Key 59 | // }[keyof DST] 60 | > 61 | 62 | type Handle__scalar, DST> = SRC extends Nil 63 | ? never 64 | : Pick< 65 | // continue processing fields that are in DST, directly pass SRC type if not in DST 66 | { 67 | [Key in keyof SRC]: Key extends keyof DST 68 | ? FieldsSelection 69 | : SRC[Key] 70 | }, 71 | // remove fields that are not scalars or are not in DST 72 | { 73 | [Key in keyof SRC]: SRC[Key] extends Nil 74 | ? never 75 | : Key extends FieldsToRemove 76 | ? never 77 | : SRC[Key] extends Scalar 78 | ? Key 79 | : Key extends keyof DST 80 | ? Key 81 | : never 82 | }[keyof SRC] 83 | > 84 | 85 | type Handle__isUnion, DST> = SRC extends Nil 86 | ? never 87 | : Omit // just return the union type 88 | 89 | type Scalar = string | number | Date | boolean | null | undefined 90 | 91 | type Anify = { [P in keyof T]?: any } 92 | 93 | type FieldsToRemove = '__isUnion' | '__scalar' | '__name' | '__args' 94 | 95 | type Nil = undefined | null 96 | -------------------------------------------------------------------------------- /src/scripts/toolsList.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 18 | import { HubMCPServer } from '../server'; 19 | import { zodToJsonSchema } from 'zod-to-json-schema'; 20 | import _ from 'lodash'; 21 | import fs from 'fs'; 22 | import path from 'path'; 23 | import { program } from 'commander'; 24 | import { ZodTypeAny } from 'zod'; 25 | 26 | const EMPTY_OBJECT_JSON_SCHEMA = { 27 | type: 'object' as const, 28 | }; 29 | 30 | program 31 | .command('check-tools-list') 32 | .description('Check if the tools list is up to date') 33 | .action(() => { 34 | let code = 0; 35 | const currentToolsList = loadCurrentToolsList(); 36 | const newToolsList = getToolDefinitionList(); 37 | if (compareToolDefinitionList(currentToolsList, newToolsList)) { 38 | console.log('Tools list is up to date. ✅'); 39 | } else { 40 | console.log('Tools list is not up to date. ❌'); 41 | code = 1; 42 | } 43 | 44 | const currentToolsNames = loadCurrentToolsNames(); 45 | const newToolsNames = newToolsList.tools.map((tool) => tool.name); 46 | if (compareToolNames(currentToolsNames, newToolsNames)) { 47 | console.log('Tools names are up to date. ✅'); 48 | } else { 49 | console.log('Tools names are not up to date. ❌'); 50 | code = 1; 51 | } 52 | 53 | process.exit(code); 54 | }); 55 | 56 | program 57 | .command('update-tools-list') 58 | .description('Update the tools list') 59 | .action(() => { 60 | const newToolsList = getToolDefinitionList(); 61 | saveToolsList(newToolsList); 62 | }); 63 | 64 | program.parse(); 65 | 66 | function getToolDefinitionList(): { tools: Tool[] } { 67 | const server = new HubMCPServer(); 68 | const tools = server.GetAssets().reduce( 69 | (acc, asset) => { 70 | const tools = asset.ListTools(); 71 | tools.forEach((tool, name) => { 72 | const toolDefinition: Tool = { 73 | name, 74 | description: tool.title, // Use title instead of description to have less noise in the tools list 75 | inputSchema: tool.inputSchema 76 | ? (zodToJsonSchema(tool.inputSchema as ZodTypeAny, { 77 | strictUnions: true, 78 | }) as Tool['inputSchema']) 79 | : EMPTY_OBJECT_JSON_SCHEMA, 80 | annotations: tool.annotations, 81 | }; 82 | 83 | if (tool.outputSchema) { 84 | toolDefinition.outputSchema = zodToJsonSchema(tool.outputSchema as ZodTypeAny, { 85 | strictUnions: true, 86 | }) as Tool['outputSchema']; 87 | } 88 | 89 | acc.tools.push(toolDefinition); 90 | }); 91 | return acc; 92 | }, 93 | { tools: [] } as { tools: Tool[] } 94 | ); 95 | return tools; 96 | } 97 | 98 | function loadCurrentToolsList(): { tools: Tool[] } { 99 | const toolsList = fs.readFileSync(path.join(__dirname, '../..', 'tools.json'), 'utf8'); 100 | return JSON.parse(toolsList); 101 | } 102 | 103 | function loadCurrentToolsNames(): string[] { 104 | const toolsList = fs.readFileSync(path.join(__dirname, '../..', 'tools.txt'), 'utf8'); 105 | return toolsList.split('\n').map((line) => line.split('- name: ')[1].replace(/^"|"$/g, '')); 106 | } 107 | 108 | function saveToolsList(toolsList: { tools: Tool[] }) { 109 | fs.writeFileSync( 110 | path.join(__dirname, '../..', 'tools.json'), 111 | JSON.stringify(toolsList, null, 2) 112 | ); 113 | 114 | fs.writeFileSync( 115 | path.join(__dirname, '../..', 'tools.txt'), 116 | toolsList.tools.map((tool) => `- name: ${tool.name}`).join('\n') 117 | ); 118 | } 119 | 120 | function compareToolDefinitionList(list1: { tools: Tool[] }, list2: { tools: Tool[] }) { 121 | return _.isEqual(list1.tools, list2.tools); 122 | } 123 | 124 | function compareToolNames(list1: string[], list2: string[]) { 125 | return _.isEqual(list1, list2); 126 | } 127 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | mcp-coc@anthropic.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import express, { Express, Request, Response } from 'express'; 18 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 19 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 20 | import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js'; 21 | import { 22 | JSONRPC_VERSION, 23 | METHOD_NOT_FOUND, 24 | INTERNAL_ERROR, 25 | } from '@modelcontextprotocol/specification/schema/2025-06-18/schema'; 26 | import { ScoutAPI } from './scout'; 27 | import { Asset } from './asset'; 28 | import { Repos } from './repos'; 29 | import { Accounts } from './accounts'; 30 | import { Search } from './search'; 31 | import { logger } from './logger'; 32 | 33 | const STDIO_OPTION = 'stdio'; 34 | const STREAMABLE_HTTP_OPTION = 'http'; 35 | 36 | export class HubMCPServer { 37 | private readonly server: Server; 38 | private readonly assets: Asset[]; 39 | 40 | constructor(username?: string, patToken?: string) { 41 | this.server = new Server( 42 | { 43 | name: 'dockerhub-mcp-server', 44 | version: '1.0.0', 45 | }, 46 | { 47 | capabilities: { 48 | tools: {}, 49 | }, 50 | } 51 | ); 52 | 53 | this.assets = [ 54 | new Repos(this.server, { 55 | name: 'repos', 56 | host: 'https://hub.docker.com/v2', 57 | auth: { 58 | type: 'pat', 59 | token: patToken, 60 | username: username, 61 | }, 62 | }), 63 | new Accounts(this.server, { 64 | name: 'accounts', 65 | host: 'https://hub.docker.com/v2', 66 | auth: { 67 | type: 'pat', 68 | token: patToken, 69 | username: username, 70 | }, 71 | }), 72 | new Search(this.server, { 73 | name: 'search', 74 | host: 'https://hub.docker.com/api/search', 75 | }), 76 | new ScoutAPI(this.server, { 77 | name: 'scout', 78 | host: 'https://api.scout.docker.com', 79 | auth: { 80 | type: 'pat', 81 | token: patToken, 82 | username: username, 83 | }, 84 | }), 85 | ]; 86 | for (const asset of this.assets) { 87 | asset.RegisterTools(); 88 | } 89 | } 90 | 91 | async run(port: number, transportType: string): Promise { 92 | let transport = null; 93 | switch (transportType) { 94 | case STDIO_OPTION: 95 | transport = new StdioServerTransport(); 96 | await this.server.connect(transport); 97 | logger.info('mcp server listening over stdio'); 98 | break; 99 | case STREAMABLE_HTTP_OPTION: { 100 | const app = express(); 101 | app.use(express.json()); 102 | this.registerRoutes(app); 103 | app.listen(port, () => { 104 | logger.info(`mcp server listening on port ${port}`); 105 | }); 106 | break; 107 | } 108 | } 109 | } 110 | 111 | private registerRoutes(app: Express) { 112 | app.post('/mcp', async (req: Request, res: Response) => { 113 | const sanitizedBody = JSON.stringify(req.body).replace(/\n|\r/g, ''); 114 | logger.info(`received mcp request: ${sanitizedBody}`); 115 | try { 116 | const transport = new StreamableHTTPServerTransport({ 117 | sessionIdGenerator: undefined, 118 | enableJsonResponse: true, 119 | }); 120 | 121 | await this.server.connect(transport); 122 | await transport.handleRequest(req, res, req.body); 123 | } catch (error) { 124 | logger.info(`error handling mcp request: ${error}`); 125 | if (!res.headersSent) { 126 | res.status(500).json({ 127 | jsonrpc: JSONRPC_VERSION, 128 | error: { 129 | code: INTERNAL_ERROR, 130 | message: 'Internal server error', 131 | }, 132 | id: null, 133 | }); 134 | } 135 | } 136 | }); 137 | 138 | app.get('/mcp', async (req: Request, res: Response) => { 139 | logger.info('received get mcp request'); 140 | res.writeHead(405).end( 141 | JSON.stringify({ 142 | jsonrpc: JSONRPC_VERSION, 143 | error: { 144 | code: METHOD_NOT_FOUND, 145 | message: 'Method not allowed.', 146 | }, 147 | id: null, 148 | }) 149 | ); 150 | }); 151 | 152 | app.delete('/mcp', async (req: Request, res: Response) => { 153 | logger.info('received delete mcp request'); 154 | res.writeHead(405).end( 155 | JSON.stringify({ 156 | jsonrpc: JSONRPC_VERSION, 157 | error: { 158 | code: METHOD_NOT_FOUND, 159 | message: 'Method not allowed.', 160 | }, 161 | id: null, 162 | }) 163 | ); 164 | }); 165 | } 166 | 167 | public GetAssets(): Asset[] { 168 | return this.assets; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/scout/genql/runtime/linkTypeMap.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import type { 3 | CompressedType, 4 | CompressedTypeMap, 5 | LinkedArgMap, 6 | LinkedField, 7 | LinkedType, 8 | LinkedTypeMap, 9 | } from './types' 10 | 11 | export interface PartialLinkedFieldMap { 12 | [field: string]: { 13 | type: string 14 | args?: LinkedArgMap 15 | } 16 | } 17 | 18 | export const linkTypeMap = ( 19 | typeMap: CompressedTypeMap, 20 | ): LinkedTypeMap => { 21 | const indexToName: Record = Object.assign( 22 | {}, 23 | ...Object.keys(typeMap.types).map((k, i) => ({ [i]: k })), 24 | ) 25 | 26 | let intermediaryTypeMap = Object.assign( 27 | {}, 28 | ...Object.keys(typeMap.types || {}).map( 29 | (k): Record => { 30 | const type: CompressedType = typeMap.types[k]! 31 | const fields = type || {} 32 | return { 33 | [k]: { 34 | name: k, 35 | // type scalar properties 36 | scalar: Object.keys(fields).filter((f) => { 37 | const [type] = fields[f] || [] 38 | 39 | const isScalar = 40 | type && typeMap.scalars.includes(type) 41 | if (!isScalar) { 42 | return false 43 | } 44 | const args = fields[f]?.[1] 45 | const argTypes = Object.values(args || {}) 46 | .map((x) => x?.[1]) 47 | .filter(Boolean) 48 | 49 | const hasRequiredArgs = argTypes.some( 50 | (str) => str && str.endsWith('!'), 51 | ) 52 | if (hasRequiredArgs) { 53 | return false 54 | } 55 | return true 56 | }), 57 | // fields with corresponding `type` and `args` 58 | fields: Object.assign( 59 | {}, 60 | ...Object.keys(fields).map( 61 | (f): PartialLinkedFieldMap => { 62 | const [typeIndex, args] = fields[f] || [] 63 | if (typeIndex == null) { 64 | return {} 65 | } 66 | return { 67 | [f]: { 68 | // replace index with type name 69 | type: indexToName[typeIndex], 70 | args: Object.assign( 71 | {}, 72 | ...Object.keys(args || {}).map( 73 | (k) => { 74 | // if argTypeString == argTypeName, argTypeString is missing, need to readd it 75 | if (!args || !args[k]) { 76 | return 77 | } 78 | const [ 79 | argTypeName, 80 | argTypeString, 81 | ] = args[k] as any 82 | return { 83 | [k]: [ 84 | indexToName[ 85 | argTypeName 86 | ], 87 | argTypeString || 88 | indexToName[ 89 | argTypeName 90 | ], 91 | ], 92 | } 93 | }, 94 | ), 95 | ), 96 | }, 97 | } 98 | }, 99 | ), 100 | ), 101 | }, 102 | } 103 | }, 104 | ), 105 | ) 106 | const res = resolveConcreteTypes(intermediaryTypeMap) 107 | return res 108 | } 109 | 110 | // replace typename with concrete type 111 | export const resolveConcreteTypes = (linkedTypeMap: LinkedTypeMap) => { 112 | Object.keys(linkedTypeMap).forEach((typeNameFromKey) => { 113 | const type: LinkedType = linkedTypeMap[typeNameFromKey]! 114 | // type.name = typeNameFromKey 115 | if (!type.fields) { 116 | return 117 | } 118 | 119 | const fields = type.fields 120 | 121 | Object.keys(fields).forEach((f) => { 122 | const field: LinkedField = fields[f]! 123 | 124 | if (field.args) { 125 | const args = field.args 126 | Object.keys(args).forEach((key) => { 127 | const arg = args[key] 128 | 129 | if (arg) { 130 | const [typeName] = arg 131 | 132 | if (typeof typeName === 'string') { 133 | if (!linkedTypeMap[typeName]) { 134 | linkedTypeMap[typeName] = { name: typeName } 135 | } 136 | 137 | arg[0] = linkedTypeMap[typeName]! 138 | } 139 | } 140 | }) 141 | } 142 | 143 | const typeName = field.type as LinkedType | string 144 | 145 | if (typeof typeName === 'string') { 146 | if (!linkedTypeMap[typeName]) { 147 | linkedTypeMap[typeName] = { name: typeName } 148 | } 149 | 150 | field.type = linkedTypeMap[typeName]! 151 | } 152 | }) 153 | }) 154 | 155 | return linkedTypeMap 156 | } 157 | -------------------------------------------------------------------------------- /src/scout.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { Asset, AssetConfig } from './asset'; 18 | import { ScoutClient } from './scout/client'; 19 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 20 | import z from 'zod'; 21 | import { logger } from './logger'; 22 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 23 | const DHI_DISCLAIMER = `Docker Hardened Images are available for organizations entitled to DHIs. If you're interested in accessing Docker Hardened Images, please visit: 24 | https://www.docker.com/products/hardened-images/`; 25 | 26 | export class ScoutAPI extends Asset { 27 | private scoutClient: ScoutClient; 28 | constructor( 29 | private server: McpServer, 30 | config: AssetConfig 31 | ) { 32 | super(config); 33 | this.scoutClient = new ScoutClient({ 34 | url: 'https://api.scout.docker.com/v1/graphql', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | fetchFn: async (input: Request | URL, init?: RequestInit) => { 39 | const headers = { 40 | ...init?.headers, 41 | 'Content-Type': 'application/json', 42 | }; 43 | const token = await this.authenticate(); 44 | (headers as Record)['Authorization'] = `Bearer ${token}`; 45 | return fetch(input, { 46 | ...init, 47 | headers, 48 | }); 49 | }, 50 | reportErrorFn: (error: Error, onErrorCallback?: () => void) => { 51 | logger.error(`❌ Scout API error: ${error.message}`); 52 | if (onErrorCallback) { 53 | onErrorCallback(); 54 | } 55 | }, 56 | }); 57 | } 58 | RegisterTools(): void { 59 | this.tools.set( 60 | 'dockerHardenedImages', 61 | this.server.registerTool( 62 | 'dockerHardenedImages', 63 | { 64 | description: 65 | 'This API is used to list Docker Hardened Images (DHIs) available in the user organisations. The tool takes the organisation name as input and returns the list of DHI images available in the organisation. It depends on the "listNamespaces" tool to be called first to get the list of organisations the user has access to.', 66 | inputSchema: z.object({ 67 | organisation: z 68 | .string() 69 | .describe( 70 | 'The organisation for which the DHIs are listed for. If user does not explicitly ask for a specific organisation, the "listNamespaces" tool should be called first to get the list of organisations the user has access to.' 71 | ), 72 | }).shape, 73 | annotations: { 74 | title: 'List available Docker Hardened Images', 75 | }, 76 | title: 'List available Docker Hardened Images in user organisations', 77 | }, 78 | this.dhis.bind(this) 79 | ) 80 | ); 81 | } 82 | private async dhis({ organisation }: { organisation: string }): Promise { 83 | logger.info(`Querying for mirrored DHI images for organization: ${organisation}`); 84 | const { data, errors } = await this.scoutClient.query({ 85 | dhiListMirroredRepositories: { 86 | __args: { 87 | context: { organization: organisation }, 88 | }, 89 | mirroredRepositories: { 90 | destinationRepository: { 91 | name: true, 92 | namespace: true, 93 | }, 94 | dhiSourceRepository: { 95 | displayName: true, 96 | namespace: true, 97 | name: true, 98 | }, 99 | }, 100 | }, 101 | }); 102 | if (errors && errors.length > 0) { 103 | const error = errors[0]; 104 | if (error.extensions?.status?.toString().includes('FORBIDDEN')) { 105 | return { 106 | content: [ 107 | { 108 | type: 'text', 109 | text: `You are not authorised to fetch DHIs for the organisation: ${organisation}. Please provide another organisation name.`, 110 | }, 111 | ], 112 | isError: true, 113 | }; 114 | } 115 | return { 116 | content: [ 117 | { type: 'text', text: JSON.stringify(errors, null, 2) }, 118 | { 119 | type: 'text', 120 | text: DHI_DISCLAIMER, 121 | }, 122 | ], 123 | isError: true, 124 | }; 125 | } 126 | 127 | if (data.dhiListMirroredRepositories?.mirroredRepositories?.length === 0) { 128 | logger.info(`No mirrored DHI images found for organization: ${organisation}`); 129 | return { 130 | content: [ 131 | { 132 | type: 'text', 133 | text: `There are no mirrored DHI images for the organization '${organisation}'. Could you try again by providing a different organization entitled to DHIs?`, 134 | }, 135 | { 136 | type: 'text', 137 | text: DHI_DISCLAIMER, 138 | }, 139 | ], 140 | }; 141 | } 142 | logger.info( 143 | `Found ${data.dhiListMirroredRepositories?.mirroredRepositories?.length} mirrored DHI images for organization: ${organisation}` 144 | ); 145 | return { 146 | content: [ 147 | { 148 | type: 'text', 149 | text: `Here are the mirrored DHI images for the organization '${organisation}':\n\n${JSON.stringify( 150 | data.dhiListMirroredRepositories?.mirroredRepositories, 151 | null, 152 | 2 153 | )}`, 154 | }, 155 | { 156 | type: 'text', 157 | text: DHI_DISCLAIMER, 158 | }, 159 | ], 160 | }; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | 3 | run-name: Release Docker Image ${{ github.event_name == 'workflow_dispatch' && '(manual)' || '(auto-deploy)' }} 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | 10 | workflow_dispatch: 11 | inputs: 12 | version: 13 | description: | 14 | Version (of the form "1.2.3") or Branch (of the form "origin/branch-name"). 15 | Leave empty to bump the latest version. 16 | type: string 17 | version_level: 18 | description: The level of the version to bump. 19 | type: choice 20 | default: 'minor' 21 | required: true 22 | options: 23 | - 'major' 24 | - 'minor' 25 | - 'patch' 26 | build_local: 27 | type: boolean 28 | default: false 29 | description: Uses build-cloud by default. If Build Cloud is down, set this to true to build locally. 30 | dry_run: 31 | description: If true, the workflow will not push the image to the registry. 32 | type: boolean 33 | default: false 34 | 35 | 36 | env: 37 | GOPRIVATE: github.com/docker 38 | NAME: dockerhub-mcp 39 | VERSION_LEVEL: ${{ inputs.version_level || 'minor' }} 40 | 41 | jobs: 42 | release: 43 | name: Release Service 44 | permissions: 45 | pull-requests: write 46 | # This permission is required to update the PR body content 47 | repository-projects: write 48 | # These permissions are needed to interact with GitHub's OIDC Token 49 | # endpoint. We need it in order to make requests to AWS ECR for image 50 | # mirroring. 51 | id-token: write 52 | contents: read 53 | runs-on: ubuntu-latest 54 | # Internally the create-release action attempts to push a commit to 55 | # cloud-manifests in a loop to avoid race-conditions. However, this could 56 | # have the side-effect of making the action hang for ever if we come across 57 | # a scenario that we haven't thought of. This timeout makes sure to fail the 58 | # workflow if that happens. 59 | timeout-minutes: 10 60 | steps: 61 | - name: Setup 62 | uses: docker/actions/setup-go@33488d0ac7cf5f3616b656b8f2bf28b45467976c #v1.17.0 63 | id: setup_go 64 | with: 65 | app_id: ${{ secrets.HUB_PLATFORM_APP_ID }} 66 | app_private_key: ${{ secrets.HUB_PLATFORM_APP_PRIVATE_KEY }} 67 | go_version: '1.24' 68 | 69 | - name: Checkout 70 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 71 | with: 72 | token: ${{ steps.setup_go.outputs.token }} 73 | fetch-depth: 0 74 | 75 | - name: Bump Version 76 | id: bump_version 77 | uses: docker/actions/bump-version@132452b833c5fae71bc674fe54384c9242173f96 # v2.5.0 78 | with: 79 | name: ${{ env.NAME }} 80 | level: ${{ env.VERSION_LEVEL }} 81 | 82 | 83 | - name: Get Release Version 84 | id: release_version 85 | shell: bash 86 | run: | 87 | if [[ '${{ steps.bump_version.outcome }}' == 'success' ]]; then 88 | echo "version=${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_OUTPUT 89 | echo "tag=${{ steps.bump_version.outputs.new_tag }}" >> $GITHUB_OUTPUT 90 | elif [[ '${{ steps.bump_version.outcome }}' == 'success' ]]; then 91 | echo "version=${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_OUTPUT 92 | elif [[ '${{ inputs.version }}' != '' ]]; then 93 | echo "Using already provided version: ${{ inputs.version }}." 94 | echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT 95 | else 96 | echo "Unable to compute version for staging environment." 97 | exit 42 98 | fi 99 | 100 | - name: Hub Login 101 | uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc #v2 102 | with: 103 | username: dockerbuildbot 104 | password: ${{ secrets.DOCKERBUILDBOT_WRITE_PAT }} 105 | 106 | - name: Setup Hydrobuild 107 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3 108 | if: ${{ ! inputs.build_local }} 109 | with: 110 | version: "lab:latest" 111 | driver: cloud 112 | endpoint: docker/platform-experience 113 | install: true 114 | 115 | - name: Check Docker image exists 116 | id: hub_image_exists 117 | shell: bash 118 | run: | 119 | if docker manifest inspect docker/${{ env.NAME }}:${{ steps.bump_version.outputs.new_version }}; then 120 | echo 'exists=true' >> $GITHUB_OUTPUT 121 | else 122 | echo 'exists=false' >> $GITHUB_OUTPUT 123 | fi 124 | 125 | - name: Ensure attestations are supported 126 | shell: bash 127 | # docker buildx inspect | grep Driver 128 | # Driver: docker 129 | # indicates that we need to enable containerd so 130 | # we can compute sboms. 131 | run: | 132 | driver=$(docker buildx inspect | grep "Driver:") 133 | if [[ "$driver" == *"docker"* ]]; then 134 | echo "detected driver, needs containerd snapshotter enabled: $driver" 135 | sudo mkdir -p /etc/docker 136 | if [ -f /etc/docker/daemon.json ]; then 137 | cat /etc/docker/daemon.json | jq '. + {"features": {"containerd-snapshotter": true}}' | sudo tee /etc/docker/daemon.json 138 | else 139 | echo '{"features": {"containerd-snapshotter": true}}' | sudo tee /etc/docker/daemon.json 140 | fi 141 | sudo systemctl restart docker 142 | fi 143 | 144 | 145 | - name: Set up QEMU 146 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 147 | 148 | - name: Set up Docker Buildx 149 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3 150 | - name: Build and push service image 151 | id: build_and_push 152 | if: steps.hub_image_exists.outputs.exists == 'false' 153 | uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 154 | with: 155 | context: . 156 | file: Dockerfile 157 | build-args: | 158 | SERVICE_NAME=${{ env.NAME }} 159 | SERVICE_VERSION=${{ steps.release_version.outputs.version }} 160 | push: ${{ inputs.dry_run != 'true' }} 161 | tags: | 162 | docker/${{ env.NAME }}:${{ steps.release_version.outputs.version }} 163 | docker/${{ env.NAME }}:latest 164 | labels: | 165 | org.opencontainers.image.revision=${{ github.event.pull_request.head.sha || github.event.after || github.event.release.tag_name }} 166 | org.opencontainers.image.source=https://github.com/${{ github.repository }} 167 | com.docker.image.source.entrypoint=Dockerfile 168 | provenance: mode=max 169 | sbom: true 170 | platforms: linux/amd64,linux/arm64 171 | 172 | - name: Delete git tag created by this workflow 173 | if: failure() && steps.bump_version.outputs.new_tag != '' 174 | shell: bash 175 | run: | 176 | git push --delete origin ${{ steps.bump_version.outputs.new_tag }} 177 | 178 | 179 | -------------------------------------------------------------------------------- /src/accounts.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; 18 | import { Asset, AssetConfig } from './asset'; 19 | import { z } from 'zod'; 20 | import { createPaginatedResponseSchema } from './types'; 21 | import { CallToolResult } from '@modelcontextprotocol/sdk/types'; 22 | import { jwtDecode, JwtPayload } from 'jwt-decode'; 23 | 24 | //#region Types 25 | const namespace = z.object({ 26 | id: z.string().describe('The ID of the namespace'), 27 | uuid: z.string().describe('The UUID of the namespace'), 28 | orgname: z.string().describe('The name of the org which is also the namespace name'), 29 | full_name: z.string().describe('The full name of the org'), 30 | location: z.string().describe('The location of the org'), 31 | company: z.string().describe('The company of the org'), 32 | profile_url: z.string().describe('The profile URL of the org'), 33 | date_joined: z.string().describe('The date joined of the namespace'), 34 | gravatar_email: z.string().describe('The gravatar email of the namespace'), 35 | gravatar_url: z.string().describe('The gravatar URL of the namespace'), 36 | type: z.string().describe('The type of the namespace'), 37 | badge: z.string().describe('The badge of the namespace'), 38 | is_active: z.boolean().describe('Whether the namespace is active'), 39 | user_role: z.string().describe('The user role of the namespace'), 40 | user_groups: z.array(z.string()).describe('The user groups of the namespace'), 41 | org_groups_count: z.number().describe('The number of org groups of the namespace'), 42 | plan_name: z.string().nullable().describe('The plan name of the namespace'), 43 | parent_name: z.string().nullable().describe('The parent name of the namespace'), 44 | }); 45 | 46 | const namespacePaginatedResponseSchema = createPaginatedResponseSchema(namespace); 47 | export type NamespacePaginatedResponse = z.infer; 48 | //#endregion 49 | 50 | export class Accounts extends Asset { 51 | constructor( 52 | private server: McpServer, 53 | config: AssetConfig 54 | ) { 55 | super(config); 56 | } 57 | 58 | RegisterTools(): void { 59 | this.tools.set( 60 | 'listNamespaces', 61 | this.server.registerTool( 62 | 'listNamespaces', 63 | { 64 | description: 'List paginated namespaces', 65 | inputSchema: { 66 | page: z 67 | .number() 68 | .optional() 69 | .describe('The page number to list repositories from'), 70 | page_size: z 71 | .number() 72 | .optional() 73 | .describe('The page size to list repositories from'), 74 | }, 75 | outputSchema: namespacePaginatedResponseSchema.shape, 76 | annotations: { 77 | title: 'List Namespaces', 78 | }, 79 | title: 'List organisations (namespaces) the user has access to', 80 | }, 81 | this.listNamespaces.bind(this) 82 | ) 83 | ); 84 | this.tools.set( 85 | 'getPersonalNamespace', 86 | this.server.registerTool( 87 | 'getPersonalNamespace', 88 | { 89 | description: 'Get the personal namespace name', 90 | annotations: { 91 | title: 'Get Personal Namespace', 92 | }, 93 | title: 'Get user personal namespace', 94 | }, 95 | this.getPersonalNamespace.bind(this) 96 | ) 97 | ); 98 | this.tools.set( 99 | 'listAllNamespacesMemberOf', 100 | this.server.registerTool( 101 | 'listAllNamespacesMemberOf', 102 | { 103 | description: 'List all namespaces the user is a member of', 104 | annotations: { 105 | title: 'List All Namespaces user is a member of', 106 | }, 107 | title: 'List all organisations (namespaces) the user is a member of including personal namespace', 108 | }, 109 | () => { 110 | return { 111 | content: [ 112 | { 113 | type: 'text', 114 | text: "To get all namespaces the user is a member of, call the 'listNamespaces' tool and the 'getPersonalNamespace' tool to get the personal namespace name.", 115 | }, 116 | ], 117 | }; 118 | } 119 | ) 120 | ); 121 | } 122 | 123 | private async listNamespaces({ 124 | page, 125 | page_size, 126 | }: { 127 | page?: number; 128 | page_size?: number; 129 | }): Promise { 130 | if (!page) { 131 | page = 1; 132 | } 133 | if (!page_size) { 134 | page_size = 10; 135 | } 136 | const url = `${this.config.host}/user/orgs?page=${page}&page_size=${page_size}`; 137 | 138 | return this.callAPI( 139 | url, 140 | { method: 'GET' }, 141 | `Here are the namespaces (Note: this list does not include the personal namespace): :response`, 142 | `Error getting repositories for ${namespace}` 143 | ); 144 | } 145 | 146 | private async getPersonalNamespace(): Promise { 147 | try { 148 | const token = await this.authenticate(); 149 | const jwt = jwtDecode< 150 | JwtPayload & { 151 | 'https://hub.docker.com': { 152 | username: string; 153 | }; 154 | } 155 | >(token); 156 | const dockerData = jwt['https://hub.docker.com']; 157 | const username = dockerData.username; 158 | return { 159 | content: [{ type: 'text', text: `The personal namespace is ${username}` }], 160 | }; 161 | } catch (error) { 162 | return { 163 | content: [ 164 | { 165 | type: 'text', 166 | text: `Error getting personal namespace: ${error}. Please provide the name of the personal namespace.`, 167 | }, 168 | ], 169 | isError: true, 170 | }; 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/scout/genql/runtime/generateGraphqlOperation.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import type { LinkedField, LinkedType } from './types' 3 | 4 | export interface Args { 5 | [arg: string]: any | undefined 6 | } 7 | 8 | export interface Fields { 9 | [field: string]: Request 10 | } 11 | 12 | export type Request = boolean | number | Fields 13 | 14 | export interface Variables { 15 | [name: string]: { 16 | value: any 17 | typing: [LinkedType, string] 18 | } 19 | } 20 | 21 | export interface Context { 22 | root: LinkedType 23 | varCounter: number 24 | variables: Variables 25 | fragmentCounter: number 26 | fragments: string[] 27 | } 28 | 29 | export interface GraphqlOperation { 30 | query: string 31 | variables?: { [name: string]: any } 32 | operationName?: string 33 | } 34 | 35 | const parseRequest = ( 36 | request: Request | undefined, 37 | ctx: Context, 38 | path: string[], 39 | ): string => { 40 | if (typeof request === 'object' && '__args' in request) { 41 | const args: any = request.__args 42 | let fields: Request | undefined = { ...request } 43 | delete fields.__args 44 | const argNames = Object.keys(args) 45 | 46 | if (argNames.length === 0) { 47 | return parseRequest(fields, ctx, path) 48 | } 49 | 50 | const field = getFieldFromPath(ctx.root, path) 51 | 52 | const argStrings = argNames.map((argName) => { 53 | ctx.varCounter++ 54 | const varName = `v${ctx.varCounter}` 55 | 56 | const typing = field.args && field.args[argName] // typeMap used here, .args 57 | 58 | if (!typing) { 59 | throw new Error( 60 | `no typing defined for argument \`${argName}\` in path \`${path.join( 61 | '.', 62 | )}\``, 63 | ) 64 | } 65 | 66 | ctx.variables[varName] = { 67 | value: args[argName], 68 | typing, 69 | } 70 | 71 | return `${argName}:$${varName}` 72 | }) 73 | return `(${argStrings})${parseRequest(fields, ctx, path)}` 74 | } else if (typeof request === 'object' && Object.keys(request).length > 0) { 75 | const fields = request 76 | const fieldNames = Object.keys(fields).filter((k) => Boolean(fields[k])) 77 | 78 | if (fieldNames.length === 0) { 79 | throw new Error( 80 | `field selection should not be empty: ${path.join('.')}`, 81 | ) 82 | } 83 | 84 | const type = 85 | path.length > 0 ? getFieldFromPath(ctx.root, path).type : ctx.root 86 | const scalarFields = type.scalar 87 | 88 | let scalarFieldsFragment: string | undefined 89 | 90 | if (fieldNames.includes('__scalar')) { 91 | const falsyFieldNames = new Set( 92 | Object.keys(fields).filter((k) => !Boolean(fields[k])), 93 | ) 94 | if (scalarFields?.length) { 95 | ctx.fragmentCounter++ 96 | scalarFieldsFragment = `f${ctx.fragmentCounter}` 97 | 98 | ctx.fragments.push( 99 | `fragment ${scalarFieldsFragment} on ${ 100 | type.name 101 | }{${scalarFields 102 | .filter((f) => !falsyFieldNames.has(f)) 103 | .join(',')}}`, 104 | ) 105 | } 106 | } 107 | 108 | const fieldsSelection = fieldNames 109 | .filter((f) => !['__scalar', '__name'].includes(f)) 110 | .map((f) => { 111 | const parsed = parseRequest(fields[f], ctx, [...path, f]) 112 | 113 | if (f.startsWith('on_')) { 114 | ctx.fragmentCounter++ 115 | const implementationFragment = `f${ctx.fragmentCounter}` 116 | 117 | const typeMatch = f.match(/^on_(.+)/) 118 | 119 | if (!typeMatch || !typeMatch[1]) 120 | throw new Error('match failed') 121 | 122 | ctx.fragments.push( 123 | `fragment ${implementationFragment} on ${typeMatch[1]}${parsed}`, 124 | ) 125 | 126 | return `...${implementationFragment}` 127 | } else { 128 | return `${f}${parsed}` 129 | } 130 | }) 131 | .concat(scalarFieldsFragment ? [`...${scalarFieldsFragment}`] : []) 132 | .join(',') 133 | 134 | return `{${fieldsSelection}}` 135 | } else { 136 | return '' 137 | } 138 | } 139 | 140 | export const generateGraphqlOperation = ( 141 | operation: 'query' | 'mutation' | 'subscription', 142 | root: LinkedType, 143 | fields?: Fields, 144 | ): GraphqlOperation => { 145 | const ctx: Context = { 146 | root: root, 147 | varCounter: 0, 148 | variables: {}, 149 | fragmentCounter: 0, 150 | fragments: [], 151 | } 152 | const result = parseRequest(fields, ctx, []) 153 | 154 | const varNames = Object.keys(ctx.variables) 155 | 156 | const varsString = 157 | varNames.length > 0 158 | ? `(${varNames.map((v) => { 159 | const variableType = ctx.variables[v].typing[1] 160 | return `$${v}:${variableType}` 161 | })})` 162 | : '' 163 | 164 | const operationName = fields?.__name || '' 165 | 166 | return { 167 | query: [ 168 | `${operation} ${operationName}${varsString}${result}`, 169 | ...ctx.fragments, 170 | ].join(','), 171 | variables: Object.keys(ctx.variables).reduce<{ [name: string]: any }>( 172 | (r, v) => { 173 | r[v] = ctx.variables[v].value 174 | return r 175 | }, 176 | {}, 177 | ), 178 | ...(operationName ? { operationName: operationName.toString() } : {}), 179 | } 180 | } 181 | 182 | export const getFieldFromPath = ( 183 | root: LinkedType | undefined, 184 | path: string[], 185 | ) => { 186 | let current: LinkedField | undefined 187 | 188 | if (!root) throw new Error('root type is not provided') 189 | 190 | if (path.length === 0) throw new Error(`path is empty`) 191 | 192 | path.forEach((f) => { 193 | const type = current ? current.type : root 194 | 195 | if (!type.fields) 196 | throw new Error(`type \`${type.name}\` does not have fields`) 197 | 198 | const possibleTypes = Object.keys(type.fields) 199 | .filter((i) => i.startsWith('on_')) 200 | .reduce( 201 | (types, fieldName) => { 202 | const field = type.fields && type.fields[fieldName] 203 | if (field) types.push(field.type) 204 | return types 205 | }, 206 | [type], 207 | ) 208 | 209 | let field: LinkedField | null = null 210 | 211 | possibleTypes.forEach((type) => { 212 | const found = type.fields && type.fields[f] 213 | if (found) field = found 214 | }) 215 | 216 | if (!field) 217 | throw new Error( 218 | `type \`${type.name}\` does not have a field \`${f}\``, 219 | ) 220 | 221 | current = field 222 | }) 223 | 224 | return current as LinkedField 225 | } 226 | -------------------------------------------------------------------------------- /src/asset.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { CallToolResult } from '@modelcontextprotocol/sdk/types'; 18 | import { logger } from './logger'; 19 | import { RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp'; 20 | import { jwtDecode } from 'jwt-decode'; 21 | 22 | export type AssetConfig = { 23 | name: string; 24 | host: string; 25 | auth?: { 26 | type: 'bearer' | 'pat'; 27 | token?: string; 28 | username?: string; 29 | }; 30 | }; 31 | 32 | export type AssetResponse = { 33 | content: T | null; 34 | isAuthenticated: boolean; 35 | code: number; 36 | }; 37 | 38 | export class Asset implements Asset { 39 | protected tools: Map; 40 | protected tokens: Map; 41 | constructor(protected config: AssetConfig) { 42 | this.tokens = new Map(); 43 | this.tools = new Map(); 44 | } 45 | RegisterTools(): void { 46 | throw new Error('Method not implemented.'); 47 | } 48 | 49 | ListTools(): Map { 50 | return this.tools; 51 | } 52 | 53 | protected async authFetch(url: string, options: RequestInit): Promise> { 54 | const headers = options.headers || { 55 | 'Content-Type': 'application/json', 56 | 'User-Agent': 'DockerHub-MCP-Server/1.0.0', 57 | }; 58 | const token = await this.authenticate(); 59 | if (token) { 60 | (headers as Record)['Authorization'] = `Bearer ${token}`; 61 | } 62 | const response = await fetch(url, { ...options, headers }); 63 | const responseText = await response.text(); 64 | if (!response.ok) { 65 | // try to get the error message from the response 66 | logger.error( 67 | `HTTP error on '${url}' with request: ${JSON.stringify( 68 | options 69 | )}\n status: ${response.status} ${response.statusText}\n error: ${responseText}` 70 | ); 71 | 72 | throw new Error( 73 | `HTTP error! status: ${response.status} ${response.statusText} ${responseText}` 74 | ); 75 | } 76 | try { 77 | return { 78 | content: responseText ? (JSON.parse(responseText) as T) : null, 79 | isAuthenticated: token !== '', 80 | code: response.status, 81 | }; 82 | } catch (err) { 83 | logger.warn(`Response is not JSON: ${responseText}. ${err}`); 84 | return { 85 | content: responseText as T, 86 | isAuthenticated: token !== '', 87 | code: response.status, 88 | }; 89 | } 90 | } 91 | 92 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 93 | protected async callAPI( 94 | url: string, 95 | options: RequestInit, 96 | outMsg?: string, 97 | errMsg?: string, 98 | unAuthMsg?: string 99 | ): Promise; 100 | protected async callAPI( 101 | url: string, 102 | options: RequestInit, 103 | outMsg?: string, 104 | errMsg?: string, 105 | unAuthMsg?: string 106 | ): Promise; 107 | protected async callAPI( 108 | url: string, 109 | options: RequestInit, 110 | outMsg?: string, 111 | errMsg?: string, 112 | unAuthMsg?: string 113 | ): Promise { 114 | logger.info(`Calling API '${url}' with request: ${JSON.stringify(options)}`); 115 | try { 116 | const response = await this.authFetch(url, options); 117 | if (outMsg?.includes(':response')) { 118 | outMsg = outMsg.replace(':response', JSON.stringify(response)); 119 | } 120 | 121 | const result: CallToolResult = { 122 | content: [ 123 | { 124 | type: 'text', 125 | text: outMsg || 'Success', 126 | }, 127 | ], 128 | }; 129 | 130 | // If T is specified (not 'any'), include structuredContent 131 | if (response.content !== null && typeof response.content === 'object') { 132 | result.structuredContent = response.content as { [x: string]: unknown }; 133 | } 134 | if (!response.isAuthenticated) { 135 | result.content.push({ 136 | type: 'text', 137 | text: `The request was not authenticated. ${unAuthMsg || ''}`, 138 | }); 139 | } 140 | logger.info(`API call '${url}' completed successfully.`); 141 | logger.debug(`Response: ${JSON.stringify(result)}`); 142 | return result; 143 | } catch (error) { 144 | logger.error(`Error calling API '${url}': ${error}`); 145 | return { 146 | content: [ 147 | { 148 | type: 'text', 149 | text: `${errMsg ? errMsg + ': ' : ''}${(error as Error).message}`, 150 | }, 151 | ], 152 | structuredContent: { error: (error as Error).message }, 153 | isError: true, 154 | }; 155 | } 156 | } 157 | 158 | protected async authenticate(): Promise { 159 | // Add authentication 160 | if (this.config.auth) { 161 | console.error(`Authenticating with ${this.config.auth.type}`); 162 | switch (this.config.auth.type) { 163 | case 'bearer': 164 | if (this.config.auth.token) { 165 | return this.config.auth.token; 166 | } 167 | break; 168 | case 'pat': { 169 | if (!this.config.auth.username || !this.config.auth.token) { 170 | logger.warn(`No username or token provided for PAT auth`); 171 | this.tokens.set(this.config.auth.username!, { 172 | token: '', 173 | expirationDate: new Date('2030-01-01'), 174 | }); 175 | return ''; 176 | } 177 | if (!this.tokens.get(this.config.auth.username!)) { 178 | const token = await this.authenticatePAT(this.config.auth.username!); 179 | // get expiration date from token 180 | const decoded = jwtDecode<{ exp: number }>(token); 181 | const expirationDate = new Date(decoded.exp * 1000); 182 | this.tokens.set(this.config.auth.username!, { token, expirationDate }); 183 | return token; 184 | } 185 | const token = this.tokens.get(this.config.auth.username!)!; 186 | if (token.expirationDate < new Date()) { 187 | // invalidate token 188 | this.tokens.delete(this.config.auth.username!); 189 | return this.authenticate(); 190 | } 191 | return token.token; 192 | } 193 | default: 194 | throw new Error(`Unsupported auth type: ${this.config.auth.type}`); 195 | } 196 | } 197 | return ''; 198 | } 199 | 200 | protected async authenticatePAT(username: string): Promise { 201 | if (!username) { 202 | throw new Error('PAT auth: Username is empty'); 203 | } 204 | console.error(`Authenticating PAT for ${username}`); 205 | const url = `https://hub.docker.com/v2/users/login`; 206 | const response = await fetch(url, { 207 | method: 'POST', 208 | headers: { 'Content-Type': 'application/json' }, 209 | body: JSON.stringify({ 210 | username: username, 211 | password: this.config.auth?.token, 212 | }), 213 | }); 214 | if (!response.ok) { 215 | throw new Error( 216 | `Failed to authenticate PAT for ${username}: ${response.status} ${response.statusText}` 217 | ); 218 | } 219 | const data = (await response.json()) as { 220 | token: string; 221 | refresh_token: string; 222 | }; 223 | return data.token; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/scout/genql/runtime/batcher.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import type { GraphqlOperation } from './generateGraphqlOperation' 3 | import { GenqlError } from './error' 4 | 5 | type Variables = Record 6 | 7 | type QueryError = Error & { 8 | message: string 9 | 10 | locations?: Array<{ 11 | line: number 12 | column: number 13 | }> 14 | path?: any 15 | rid: string 16 | details?: Record 17 | } 18 | type Result = { 19 | data: Record 20 | errors: Array 21 | } 22 | type Fetcher = ( 23 | batchedQuery: GraphqlOperation | Array, 24 | ) => Promise> 25 | type Options = { 26 | batchInterval?: number 27 | shouldBatch?: boolean 28 | maxBatchSize?: number 29 | } 30 | type Queue = Array<{ 31 | request: GraphqlOperation 32 | resolve: (...args: Array) => any 33 | reject: (...args: Array) => any 34 | }> 35 | 36 | /** 37 | * takes a list of requests (queue) and batches them into a single server request. 38 | * It will then resolve each individual requests promise with the appropriate data. 39 | * @private 40 | * @param {QueryBatcher} client - the client to use 41 | * @param {Queue} queue - the list of requests to batch 42 | */ 43 | function dispatchQueueBatch(client: QueryBatcher, queue: Queue): void { 44 | let batchedQuery: any = queue.map((item) => item.request) 45 | 46 | if (batchedQuery.length === 1) { 47 | batchedQuery = batchedQuery[0] 48 | } 49 | (() => { 50 | try { 51 | return client.fetcher(batchedQuery); 52 | } catch(e) { 53 | return Promise.reject(e); 54 | } 55 | })().then((responses: any) => { 56 | if (queue.length === 1 && !Array.isArray(responses)) { 57 | if (responses.errors && responses.errors.length) { 58 | queue[0].reject( 59 | new GenqlError(responses.errors, responses.data), 60 | ) 61 | return 62 | } 63 | 64 | queue[0].resolve(responses) 65 | return 66 | } else if (responses.length !== queue.length) { 67 | throw new Error('response length did not match query length') 68 | } 69 | 70 | for (let i = 0; i < queue.length; i++) { 71 | if (responses[i].errors && responses[i].errors.length) { 72 | queue[i].reject( 73 | new GenqlError(responses[i].errors, responses[i].data), 74 | ) 75 | } else { 76 | queue[i].resolve(responses[i]) 77 | } 78 | } 79 | }) 80 | .catch((e) => { 81 | for (let i = 0; i < queue.length; i++) { 82 | queue[i].reject(e) 83 | } 84 | }); 85 | } 86 | 87 | /** 88 | * creates a list of requests to batch according to max batch size. 89 | * @private 90 | * @param {QueryBatcher} client - the client to create list of requests from from 91 | * @param {Options} options - the options for the batch 92 | */ 93 | function dispatchQueue(client: QueryBatcher, options: Options): void { 94 | const queue = client._queue 95 | const maxBatchSize = options.maxBatchSize || 0 96 | client._queue = [] 97 | 98 | if (maxBatchSize > 0 && maxBatchSize < queue.length) { 99 | for (let i = 0; i < queue.length / maxBatchSize; i++) { 100 | dispatchQueueBatch( 101 | client, 102 | queue.slice(i * maxBatchSize, (i + 1) * maxBatchSize), 103 | ) 104 | } 105 | } else { 106 | dispatchQueueBatch(client, queue) 107 | } 108 | } 109 | /** 110 | * Create a batcher client. 111 | * @param {Fetcher} fetcher - A function that can handle the network requests to graphql endpoint 112 | * @param {Options} options - the options to be used by client 113 | * @param {boolean} options.shouldBatch - should the client batch requests. (default true) 114 | * @param {integer} options.batchInterval - duration (in MS) of each batch window. (default 6) 115 | * @param {integer} options.maxBatchSize - max number of requests in a batch. (default 0) 116 | * @param {boolean} options.defaultHeaders - default headers to include with every request 117 | * 118 | * @example 119 | * const fetcher = batchedQuery => fetch('path/to/graphql', { 120 | * method: 'post', 121 | * headers: { 122 | * Accept: 'application/json', 123 | * 'Content-Type': 'application/json', 124 | * }, 125 | * body: JSON.stringify(batchedQuery), 126 | * credentials: 'include', 127 | * }) 128 | * .then(response => response.json()) 129 | * 130 | * const client = new QueryBatcher(fetcher, { maxBatchSize: 10 }) 131 | */ 132 | 133 | export class QueryBatcher { 134 | fetcher: Fetcher 135 | _options: Options 136 | _queue: Queue 137 | 138 | constructor( 139 | fetcher: Fetcher, 140 | { 141 | batchInterval = 6, 142 | shouldBatch = true, 143 | maxBatchSize = 0, 144 | }: Options = {}, 145 | ) { 146 | this.fetcher = fetcher 147 | this._options = { 148 | batchInterval, 149 | shouldBatch, 150 | maxBatchSize, 151 | } 152 | this._queue = [] 153 | } 154 | 155 | /** 156 | * Fetch will send a graphql request and return the parsed json. 157 | * @param {string} query - the graphql query. 158 | * @param {Variables} variables - any variables you wish to inject as key/value pairs. 159 | * @param {[string]} operationName - the graphql operationName. 160 | * @param {Options} overrides - the client options overrides. 161 | * 162 | * @return {promise} resolves to parsed json of server response 163 | * 164 | * @example 165 | * client.fetch(` 166 | * query getHuman($id: ID!) { 167 | * human(id: $id) { 168 | * name 169 | * height 170 | * } 171 | * } 172 | * `, { id: "1001" }, 'getHuman') 173 | * .then(human => { 174 | * // do something with human 175 | * console.log(human); 176 | * }); 177 | */ 178 | fetch( 179 | query: string, 180 | variables?: Variables, 181 | operationName?: string, 182 | overrides: Options = {}, 183 | ): Promise { 184 | const request: GraphqlOperation = { 185 | query, 186 | } 187 | const options = Object.assign({}, this._options, overrides) 188 | 189 | if (variables) { 190 | request.variables = variables 191 | } 192 | 193 | if (operationName) { 194 | request.operationName = operationName 195 | } 196 | 197 | const promise = new Promise((resolve, reject) => { 198 | this._queue.push({ 199 | request, 200 | resolve, 201 | reject, 202 | }) 203 | 204 | if (this._queue.length === 1) { 205 | if (options.shouldBatch) { 206 | setTimeout( 207 | () => dispatchQueue(this, options), 208 | options.batchInterval, 209 | ) 210 | } else { 211 | dispatchQueue(this, options) 212 | } 213 | } 214 | }) 215 | return promise 216 | } 217 | 218 | /** 219 | * Fetch will send a graphql request and return the parsed json. 220 | * @param {string} query - the graphql query. 221 | * @param {Variables} variables - any variables you wish to inject as key/value pairs. 222 | * @param {[string]} operationName - the graphql operationName. 223 | * @param {Options} overrides - the client options overrides. 224 | * 225 | * @return {Promise>} resolves to parsed json of server response 226 | * 227 | * @example 228 | * client.forceFetch(` 229 | * query getHuman($id: ID!) { 230 | * human(id: $id) { 231 | * name 232 | * height 233 | * } 234 | * } 235 | * `, { id: "1001" }, 'getHuman') 236 | * .then(human => { 237 | * // do something with human 238 | * console.log(human); 239 | * }); 240 | */ 241 | forceFetch( 242 | query: string, 243 | variables?: Variables, 244 | operationName?: string, 245 | overrides: Options = {}, 246 | ): Promise { 247 | const request: GraphqlOperation = { 248 | query, 249 | } 250 | const options = Object.assign({}, this._options, overrides, { 251 | shouldBatch: false, 252 | }) 253 | 254 | if (variables) { 255 | request.variables = variables 256 | } 257 | 258 | if (operationName) { 259 | request.operationName = operationName 260 | } 261 | 262 | const promise = new Promise((resolve, reject) => { 263 | const client = new QueryBatcher(this.fetcher, this._options) 264 | client._queue = [ 265 | { 266 | request, 267 | resolve, 268 | reject, 269 | }, 270 | ] 271 | dispatchQueue(client, options) 272 | }) 273 | return promise 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/scout/client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { createClient, GenqlError, Client, FieldsSelection } from './genql'; 18 | import type { Mutation, MutationGenqlSelection, Query, QueryGenqlSelection } from './genql/schema'; 19 | 20 | /** 21 | * @see https://spec.graphql.org/October2021/#example-8b658 22 | */ 23 | interface GraphqlError { 24 | message: string; 25 | path?: string[]; 26 | locations?: Array<{ line: number; column: number }>; 27 | extensions?: KnownErrorExtensions & Record; 28 | } 29 | 30 | /** 31 | * @see https://spec.graphql.org/October2021/#sec-Errors 32 | * 33 | * According to the GraphQL specification, when a request errors out the server 34 | * can send, along with the error, some additional information in the `extensions` field. 35 | * This is a completely arbitrary object, and its structure is completely up to each 36 | * individual GraphQL implementation. 37 | * 38 | * For Scout these are the currently known extensions properties: 39 | * - `code`: An enum of possible error codes e.g. "DOWNSTREAM_SERVICE_ERROR". 40 | * - `status`: The HTTP status code of the error. 41 | * - `arguments`: The arguments passed to the query / mutation. 42 | */ 43 | interface KnownErrorExtensions { 44 | code?: string; 45 | status?: number; 46 | arguments?: Record; 47 | } 48 | 49 | type GraphQLSuccessfulResponse< 50 | OperationType extends Query | Mutation, 51 | OperationSelection extends QueryGenqlSelection | MutationGenqlSelection, 52 | > = { 53 | data: FieldsSelection; 54 | errors: null; 55 | }; 56 | 57 | type GraphQLErrorResponse< 58 | OperationType extends Query | Mutation, 59 | OperationSelection extends QueryGenqlSelection | MutationGenqlSelection, 60 | > = { 61 | data: Partial>; 62 | errors: GraphqlError[]; 63 | }; 64 | 65 | type GraphQLResponse< 66 | OperationType extends Query | Mutation, 67 | OperationSelection extends QueryGenqlSelection | MutationGenqlSelection, 68 | > = 69 | | GraphQLErrorResponse 70 | | GraphQLSuccessfulResponse; 71 | 72 | export class ScoutClient { 73 | #genqlClient: Client; 74 | #reportErrorFn: (error: Error, onErrorCallback?: () => void) => void; 75 | 76 | constructor(options: { 77 | url: string; 78 | headers?: HeadersInit; 79 | fetchFn: (input: Request | URL, init?: RequestInit) => Promise; 80 | reportErrorFn: (error: Error, onErrorCallback?: () => void) => void; 81 | }) { 82 | const { url, headers, fetchFn, reportErrorFn } = options; 83 | 84 | this.#genqlClient = createClient({ url, headers, fetch: fetchFn }); 85 | this.#reportErrorFn = reportErrorFn; 86 | } 87 | 88 | // static fromLoader(request: Request, context: DockerAppLoadContext) { 89 | // let scoutEndpoint = ''; 90 | 91 | // if (context.config.DOCKER_RELEASE_STAGE === 'production') { 92 | // scoutEndpoint = 'https://api.scout.docker.com/v1/graphql'; 93 | // } else { 94 | // scoutEndpoint = 'https://api.scout-stage.docker.com/v1/graphql'; 95 | // } 96 | 97 | // const correlationId = request.headers.get(DOCKER_CORRELATION_ID_HEADER); 98 | 99 | // return new ScoutClient({ 100 | // url: scoutEndpoint, 101 | // fetchFn: context.fetch, 102 | // reportErrorFn: (error: Error, onErrorCallback?: OnErrorCallback) => { 103 | // context.reportError(error, onErrorCallback); 104 | // }, 105 | // headers: { 106 | // ...(correlationId 107 | // ? { [SCOUT_CORRELATION_ID_HEADER]: correlationId } 108 | // : undefined), 109 | // }, 110 | // }); 111 | // } 112 | 113 | async queryIf( 114 | condition: boolean, 115 | query: Q 116 | ): Promise> { 117 | if (!condition) { 118 | return { 119 | data: {}, 120 | errors: null, 121 | } as GraphQLSuccessfulResponse; 122 | } 123 | 124 | return this.query(query); 125 | } 126 | 127 | async query(query: Q): Promise> { 128 | try { 129 | const graphqlResponse = await this.#genqlClient.query(query); 130 | 131 | return { 132 | data: graphqlResponse, 133 | errors: null, 134 | }; 135 | } catch (exception: unknown) { 136 | // If the GenQL client detects that the response contains at least one error, 137 | // it will throw a GenqlError. 138 | if (exception instanceof GenqlError) { 139 | const exc = exception as GenqlError; 140 | const exceptionData = (exc.data ?? {}) as Partial>; 141 | 142 | if (this.#shouldReportError(exc.errors)) { 143 | this.#reportErrorFn( 144 | new Error( 145 | `Scout API error - ${Object.keys(query).join('_')} - ${exc.message}` 146 | ) 147 | ); 148 | } 149 | 150 | return { 151 | data: exceptionData, 152 | errors: exc.errors, 153 | } as GraphQLErrorResponse; 154 | } 155 | 156 | // This is an unknown error so let's report it and return an empty response. 157 | this.#reportErrorFn( 158 | new Error( 159 | `Scout API unknown error - ${ 160 | exception instanceof Error 161 | ? exception.message 162 | : JSON.stringify(exception, null, 2) 163 | }` 164 | ) 165 | ); 166 | 167 | return { 168 | data: {}, 169 | errors: [], 170 | } as GraphQLErrorResponse; 171 | } 172 | } 173 | 174 | async mutation( 175 | mutation: M 176 | ): Promise> { 177 | try { 178 | const result = await this.#genqlClient.mutation(mutation); 179 | 180 | return { 181 | data: result, 182 | errors: null, 183 | }; 184 | } catch (exception: unknown) { 185 | // If the GenQL client detects that the response contains at least one error, 186 | // it will throw a GenqlError. 187 | if (exception instanceof GenqlError) { 188 | const exc = exception as GenqlError; 189 | const exceptionData = (exc.data ?? {}) as Partial>; 190 | 191 | if (this.#shouldReportError(exc.errors)) { 192 | this.#reportErrorFn( 193 | new Error( 194 | `Scout API error - ${Object.keys(mutation).join('_')} - ${exc.message}` 195 | ) 196 | ); 197 | } 198 | 199 | return { 200 | data: exceptionData, 201 | errors: exc.errors, 202 | } as GraphQLErrorResponse; 203 | } 204 | 205 | // This is an unknown error so let's report it and return an empty response. 206 | this.#reportErrorFn( 207 | new Error( 208 | `Scout API unknown error - ${ 209 | exception instanceof Error 210 | ? exception.message 211 | : JSON.stringify(exception, null, 2) 212 | }` 213 | ) 214 | ); 215 | 216 | return { 217 | data: {}, 218 | errors: [], 219 | } as GraphQLErrorResponse; 220 | } 221 | } 222 | 223 | #shouldReportError(graphQLErrors: GraphqlError[]) { 224 | // 402 - Scout APIs will return a 402 if the entitlements have been exceeded. 225 | // 403 - Scout APIs will return a 403 if the user doesn't have permission to access / update a resource. 226 | const ignoreStatusCodes = [402, 403]; 227 | 228 | return graphQLErrors.some(({ extensions }) => { 229 | return ( 230 | typeof extensions?.status === 'number' && 231 | !ignoreStatusCodes.includes(extensions.status) 232 | ); 233 | }); 234 | } 235 | } 236 | 237 | export enum ScoutEndpoint { 238 | DHI_REPOSITORIES = 'dhiRepositories', 239 | DHI_REPOSITORY_CATEGORIES = 'dhiRepositoryCategories', 240 | DHI_REPOSITORY_ITEMS = 'dhiRepositoryItems', 241 | } 242 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [2025] [Docker, Inc.] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Docker Hub MCP Server 2 | 3 | Thank you for your interest in contributing to the Docker Hub Model Context Protocol (MCP) server! This document provides guidelines and instructions for contributing. 4 | 5 | ## Types of Contributions 6 | 7 | ### 1. New Tools 8 | 9 | The repository contains reference tools, please try to keep consistency as much as possible. 10 | 11 | - Check the [modelcontextprotocol.io](https://modelcontextprotocol.io) documentation 12 | - Ensure your tool doesn't duplicate existing functionality 13 | - Consider whether your tool would be generally useful to others 14 | - Follow [security best practices](https://modelcontextprotocol.io/docs/concepts/transports#security-considerations) from the MCP documentation 15 | - Ensure the [MCP sdk](https://github.com/modelcontextprotocol/typescript-sdk) does not already provide helpers/functions before creating custom code. 16 | - Update [README.md](./README.md) with instructions and examples. 17 | 18 | ### 2. Improvements to Existing Tools 19 | 20 | Enhancements to existing tools are welcome! This includes: 21 | 22 | - Bug fixes 23 | - Performance improvements 24 | - New features/parameters 25 | - Security enhancements 26 | 27 | ### 3. Documentation 28 | 29 | Documentation improvements are always welcome: 30 | 31 | - Fixing typos or unclear instructions 32 | - Adding examples 33 | - Improving setup instructions 34 | - Adding troubleshooting guides 35 | 36 | ## Getting Started 37 | 38 | 1. Fork the repository 39 | 2. Clone your fork: 40 | ```bash 41 | git clone https://github.com/your-username/dockerhub-mcp.git 42 | ``` 43 | 3. Add the upstream remote: 44 | ```bash 45 | git remote add upstream https://github.com/docker/hub-mcp.git 46 | ``` 47 | 4. Create a branch: 48 | ```bash 49 | git checkout -b my-feature 50 | ``` 51 | 52 | ## Development Guidelines 53 | 54 | This section gives the experienced contributor some tips and guidelines. 55 | 56 | ### Pull requests are always welcome 57 | 58 | Not sure if that typo is worth a pull request? Found a bug and know how to fix 59 | it? Do it! We will appreciate it. Any significant change, like adding a backend, 60 | should be documented as 61 | [a GitHub issue](https://github.com/docker/hub-mcp/issues) 62 | before anybody starts working on it. 63 | 64 | We are always thrilled to receive pull requests. We do our best to process them 65 | quickly. If your pull request is not accepted on the first try, 66 | don't get discouraged! 67 | 68 | ### Talking to other Docker users and contributors 69 | 70 | 71 | 72 | 73 | 74 | 75 | 78 | 79 | 80 | 81 | 86 | 87 | 88 | 89 | 94 | 95 | 96 | 97 | 102 | 103 |
Community Slack 76 | The Docker Community has a dedicated Slack chat to discuss features and issues. You can sign-up with this link. 77 |
Forums 82 | A public forum for users to discuss questions and explore current design patterns and 83 | best practices about Docker and related projects in the Docker Ecosystem. To participate, 84 | just log in with your Docker Hub account on https://forums.docker.com. 85 |
Twitter 90 | You can follow Docker's Twitter feed 91 | to get updates on our products. You can also tweet us questions or just 92 | share blogs or stories. 93 |
Stack Overflow 98 | Stack Overflow has over 17000 Docker questions listed. We regularly 99 | monitor Docker questions 100 | and so do many other knowledgeable Docker users. 101 |
104 | 105 | ### Conventions 106 | 107 | Fork the repository and make changes on your fork in a feature branch: 108 | 109 | - If it's a bug fix branch, name it XXXX-something where XXXX is the number of 110 | the issue. 111 | - If it's a feature branch, create an enhancement issue to announce 112 | your intentions, and name it XXXX-something where XXXX is the number of the 113 | issue. 114 | 115 | Write clean code. Universally formatted code promotes ease of writing, reading, 116 | and maintenance. Always run `npm run lint` and `npm run format:fix` before 117 | committing your changes. Most editors have plug-ins helping reducing the time spent on fixing linting issues. 118 | 119 | Pull request descriptions should be as clear as possible and include a reference 120 | to all the issues that they address. 121 | 122 | Commit messages must start with a capitalized and short summary (max. 50 chars) 123 | written in the imperative, followed by an optional, more detailed explanatory 124 | text which is separated from the summary by an empty line. 125 | 126 | Code review comments may be added to your pull request. Discuss, then make the 127 | suggested modifications and push additional commits to your feature branch. Post 128 | a comment after pushing. New commits show up in the pull request automatically, 129 | but the reviewers are notified only when you comment. 130 | 131 | Pull requests must be cleanly rebased on top of the base branch without multiple branches 132 | mixed into the PR. 133 | 134 | **Git tip**: If your PR no longer merges cleanly, use `rebase main` in your 135 | feature branch to update your pull request rather than `merge main`. 136 | 137 | Before you make a pull request, squash your commits into logical units of work 138 | using `git rebase -i` and `git push -f`. A logical unit of work is a consistent 139 | set of patches that should be reviewed together: for example, upgrading the 140 | version of a vendored dependency and taking advantage of its now available new 141 | feature constitute two separate units of work. Implementing a new function and 142 | calling it in another file constitute a single logical unit of work. The very 143 | high majority of submissions should have a single commit, so if in doubt: squash 144 | down to one. 145 | 146 | After every commit, make sure to test tools behavior. Include documentation 147 | changes in the same pull request so that a revert would remove all traces of 148 | the feature or fix. 149 | 150 | Include an issue reference like `Closes #XXXX` or `Fixes #XXXX` in the pull 151 | request description that closes an issue. Including references automatically 152 | closes the issue on a merge. 153 | 154 | Please see the [Code Style](#code-style) for further guidelines. 155 | 156 | ### Sign your work 157 | 158 | The sign-off is a simple line at the end of the explanation for the patch. Your 159 | signature certifies that you wrote the patch or otherwise have the right to pass 160 | it on as an open-source patch. The rules are pretty simple: if you can certify 161 | the below (from [developercertificate.org](https://developercertificate.org/)): 162 | 163 | ``` 164 | Developer Certificate of Origin 165 | Version 1.1 166 | 167 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 168 | 660 York Street, Suite 102, 169 | San Francisco, CA 94110 USA 170 | 171 | Everyone is permitted to copy and distribute verbatim copies of this 172 | license document, but changing it is not allowed. 173 | 174 | Developer's Certificate of Origin 1.1 175 | 176 | By making a contribution to this project, I certify that: 177 | 178 | (a) The contribution was created in whole or in part by me and I 179 | have the right to submit it under the open source license 180 | indicated in the file; or 181 | 182 | (b) The contribution is based upon previous work that, to the best 183 | of my knowledge, is covered under an appropriate open source 184 | license and I have the right under that license to submit that 185 | work with modifications, whether created in whole or in part 186 | by me, under the same open source license (unless I am 187 | permitted to submit under a different license), as indicated 188 | in the file; or 189 | 190 | (c) The contribution was provided directly to me by some other 191 | person who certified (a), (b) or (c) and I have not modified 192 | it. 193 | 194 | (d) I understand and agree that this project and the contribution 195 | are public and that a record of the contribution (including all 196 | personal information I submit with it, including my sign-off) is 197 | maintained indefinitely and may be redistributed consistent with 198 | this project or the open source license(s) involved. 199 | ``` 200 | 201 | Then you just add a line to every git commit message: 202 | 203 | Signed-off-by: Joe Smith 204 | 205 | Use your real name (sorry, no pseudonyms or anonymous contributions.) 206 | 207 | If you set your `user.name` and `user.email` git configs, you can sign your 208 | commit automatically with `git commit -s`. 209 | 210 | ## Docker community guidelines 211 | 212 | We want to keep the Docker community awesome, growing and collaborative. We need 213 | your help to keep it that way. To help with this we've come up with some general 214 | guidelines for the community as a whole: 215 | 216 | - Be nice: Be courteous, respectful and polite to fellow community members: 217 | no regional, racial, gender or other abuse will be tolerated. We like 218 | nice people way better than mean ones! 219 | 220 | - Encourage diversity and participation: Make everyone in our community feel 221 | welcome, regardless of their background and the extent of their 222 | contributions, and do everything possible to encourage participation in 223 | our community. 224 | 225 | - Keep it legal: Basically, don't get us in trouble. Share only content that 226 | you own, do not share private or sensitive information, and don't break 227 | the law. 228 | 229 | - Stay on topic: Make sure that you are posting to the correct channel and 230 | avoid off-topic discussions. Remember when you update an issue or respond 231 | to an email you are potentially sending it to a large number of people. Please 232 | consider this before you update. Also, remember that nobody likes spam. 233 | 234 | - Don't send emails to the maintainers: There's no need to send emails to the 235 | maintainers to ask them to investigate an issue or to take a look at a 236 | pull request. Instead of sending an email, GitHub mentions should be 237 | used to ping maintainers to review a pull request, a proposal or an 238 | issue. 239 | 240 | ### Code Style 241 | 242 | - Follow the existing code style in the repository 243 | - Include appropriate type definitions 244 | - Add comments for complex logic 245 | - Make sure to run linting (`npm run lint`) 246 | 247 | ### Documentation 248 | 249 | - Document all configuration options if required 250 | - Provide setup instructions if required 251 | - Include usage examples 252 | 253 | ### Security 254 | 255 | - Follow security best practices 256 | - Implement proper input validation 257 | - Handle errors appropriately 258 | - Document security considerations 259 | 260 | ## Submitting Changes 261 | 262 | 1. Commit your changes: 263 | ```bash 264 | git add . 265 | git commit -m "Description of changes" 266 | ``` 267 | 2. Push to your fork: 268 | ```bash 269 | git push origin my-feature 270 | ``` 271 | 3. Create a Pull Request through GitHub 272 | 273 | ### Pull Request Guidelines 274 | 275 | - Thoroughly test your changes 276 | - Fill out the [pull request template](.github/pull_request_template.md) completely 277 | - Link any related issues 278 | - Provide clear description of changes 279 | - Include any necessary documentation updates 280 | - Add screenshots from the MCP inspector or MCP clients if helpful 281 | - List any breaking changes 282 | 283 | ## Community 284 | 285 | - Participate in [GitHub Discussions](https://github.com/orgs/modelcontextprotocol/discussions) 286 | - Follow the [Code of Conduct](CODE_OF_CONDUCT.md) 287 | 288 | ## Questions? 289 | 290 | - Check the [documentation](https://modelcontextprotocol.io) 291 | - Ask in GitHub Discussions 292 | 293 | Thank you for contributing to Docker Hub MCP Server! 294 | -------------------------------------------------------------------------------- /src/search.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; 18 | import { Asset, AssetConfig } from './asset'; 19 | import { z } from 'zod'; 20 | import { CallToolResult } from '@modelcontextprotocol/sdk/types'; 21 | import { logger } from './logger'; 22 | 23 | //#region Types 24 | const searchResult = z.object({ 25 | id: z.string().describe('The id of the repository'), 26 | name: z.string().describe('The name of the repository in the format of namespace/repository'), 27 | slug: z.string().describe('The slug of the repository'), 28 | type: z 29 | .enum(['image', 'plugin', 'extension']) 30 | .describe('The type of the repository. Can be "image", "plugin" or "extension"'), 31 | publisher: z.object({ 32 | id: z.string().describe('The id of the publisher'), 33 | name: z.string().describe('The name of the publisher'), 34 | }), 35 | created_at: z.string().describe('The date and time the repository was created'), 36 | updated_at: z.string().describe('The date and time the repository was last updated'), 37 | short_description: z.string().describe('The short description of the repository'), 38 | badge: z 39 | .enum(['official', 'verified_publisher', 'open_source', 'none']) 40 | .nullable() 41 | .describe( 42 | "The badge of the repository. If the repository is from community publisher, the badge is either 'none' or null." 43 | ), 44 | star_count: z.number().describe('The number of stars the repository has'), 45 | pull_count: z.string().describe('The number of pulls the repository has'), 46 | operating_systems: z.array( 47 | z.object({ 48 | name: z.string().describe('The name of the operating system'), 49 | label: z.string().describe('The label of the operating system'), 50 | }) 51 | ), 52 | architectures: z.array( 53 | z.object({ 54 | name: z.string().describe('The name of the architecture'), 55 | label: z.string().describe('The label of the architecture'), 56 | }) 57 | ), 58 | logo_url: z 59 | .object({ 60 | large: z.string().nullable().optional().describe('The URL of the large logo'), 61 | small: z.string().nullable().optional().describe('The URL of the small logo'), 62 | }) 63 | .optional() 64 | .nullable(), 65 | extension_reviewed: z.boolean().describe('Whether the repository is reviewed'), 66 | categories: z.array( 67 | z.object({ 68 | slug: z.string().describe('The slug of the category'), 69 | name: z.string().describe('The name of the category'), 70 | }) 71 | ), 72 | archived: z.boolean().describe('Whether the repository is archived'), 73 | }); 74 | 75 | const searchResults = z.object({ 76 | total: z.number().optional().describe('The total number of repositories found'), 77 | results: z.array(searchResult).optional().describe('The repositories found'), 78 | error: z.string().optional().nullable(), 79 | }); 80 | 81 | //#endregion 82 | 83 | export class Search extends Asset { 84 | constructor( 85 | private server: McpServer, 86 | config: AssetConfig 87 | ) { 88 | super(config); 89 | } 90 | 91 | RegisterTools(): void { 92 | this.tools.set( 93 | 'search', 94 | this.server.registerTool( 95 | 'search', 96 | { 97 | description: 98 | 'Search for repositories in Docker Hub. It sorts results by best match if no sort criteria is provided. If user asks for secure, production-ready images the "dockerHardenedImages" tool should be called first to get the list of DHI images available in the user organisations (if any) and fallback to search tool if no DHI images are available or user is not authenticated.', 99 | inputSchema: { 100 | query: z.string().describe('The query to search for'), 101 | badges: z 102 | .array(z.enum(['official', 'verified_publisher', 'open_source'])) 103 | .optional() 104 | .describe('The trusted content to search for'), 105 | type: z 106 | .string() 107 | .optional() 108 | .describe('The type of the repository to search for'), 109 | categories: z 110 | .array(z.string()) 111 | .optional() 112 | .describe('The categories names to filter search results'), 113 | architectures: z 114 | .array(z.string()) 115 | .optional() 116 | .describe('The architectures to filter search results'), 117 | operating_systems: z 118 | .array(z.string()) 119 | .optional() 120 | .describe('The operating systems to filter search results'), 121 | extension_reviewed: z 122 | .boolean() 123 | .optional() 124 | .describe( 125 | 'Whether to filter search results to only include reviewed extensions' 126 | ), 127 | from: z.number().optional().describe('The number of repositories to skip'), 128 | size: z 129 | .number() 130 | .optional() 131 | .describe('The number of repositories to return'), 132 | sort: z 133 | .enum(['pull_count', 'updated_at']) 134 | .optional() 135 | .nullable() 136 | .describe( 137 | 'The criteria to sort the search results by. If the `sort` field is not set, the best match is used by default. When search extensions, documents are sort alphabetically if none is provided. Do not use it unless user explicitly asks for it.' 138 | ), 139 | order: z 140 | .enum(['asc', 'desc']) 141 | .optional() 142 | .nullable() 143 | .describe('The order to sort the search results by'), 144 | images: z 145 | .array(z.string()) 146 | .optional() 147 | .describe('The images to filter search results'), 148 | }, 149 | outputSchema: searchResults.shape, 150 | annotations: { 151 | title: 'Search Repositories', 152 | }, 153 | title: 'Search Repositories', 154 | }, 155 | this.search.bind(this) 156 | ) 157 | ); 158 | } 159 | 160 | private async search(request: { 161 | query: string; 162 | badges?: string[]; 163 | type?: string; 164 | categories?: string[]; 165 | architectures?: string[]; 166 | operating_systems?: string[]; 167 | extension_reviewed?: boolean; 168 | from?: number; 169 | size?: number; 170 | sort?: 'pull_count' | 'updated_at' | null; 171 | order?: 'asc' | 'desc' | null; 172 | images?: string[]; 173 | }): Promise { 174 | logger.info(`Searching for repositories with request: ${JSON.stringify(request)}`); 175 | let url = `${this.config.host}/v4?custom_boosted_results=true`; 176 | if (!request.query) { 177 | return { 178 | content: [{ type: 'text', text: 'Please provide a query to search for' }], 179 | structuredContent: {}, 180 | isError: true, 181 | }; 182 | } 183 | const queryParams = new URLSearchParams(); 184 | for (const key in request) { 185 | const param = key as keyof typeof request; 186 | switch (param) { 187 | case 'badges': 188 | case 'categories': 189 | case 'architectures': 190 | case 'operating_systems': 191 | case 'images': { 192 | if (request[param] && request[param].length > 0) { 193 | queryParams.set(param, request[param].join(',')); 194 | } 195 | break; 196 | } 197 | case 'query': 198 | case 'type': 199 | case 'order': 200 | case 'sort': 201 | case 'from': 202 | case 'size': { 203 | if ( 204 | request[param] !== undefined && 205 | request[param] !== null && 206 | request[param] !== '' 207 | ) { 208 | queryParams.set(param, request[param].toString()); 209 | logger.info(`Setting parameter: ${param} to ${request[param]}`); 210 | } 211 | break; 212 | } 213 | case 'extension_reviewed': { 214 | if (request[param]) { 215 | queryParams.set(param, 'true'); 216 | } 217 | break; 218 | } 219 | default: { 220 | logger.warn(`Unknown parameter: ${param}`); 221 | break; 222 | } 223 | } 224 | } 225 | if (queryParams.size > 0) { 226 | url += `&${queryParams.toString()}`; 227 | } 228 | const response = await this.callAPI( 229 | url, 230 | { method: 'GET' }, 231 | `Here are the search results: :response`, 232 | `Error finding repositories for query: ${request.query}` 233 | ); 234 | // let's try to find if the query is an exact match 235 | if (!response.isError) { 236 | if (response.structuredContent) { 237 | try { 238 | const results = searchResults.parse(response.structuredContent).results; 239 | if (results && results.length > 0) { 240 | const [namespace, repository] = results[0].name.split('/'); 241 | if ( 242 | !namespace.toLowerCase().includes(request.query.toLowerCase()) || 243 | (repository && 244 | !repository.toLowerCase().includes(request.query.toLowerCase())) 245 | ) { 246 | return { 247 | content: [ 248 | { 249 | type: 'text', 250 | text: `We could not find any repository exactly matching '${request.query}'. However we found some repositories that might be relevant.`, 251 | }, 252 | ...response.content, 253 | ], 254 | structuredContent: response.structuredContent, 255 | }; 256 | } 257 | } 258 | } catch (error) { 259 | logger.error(`Error parsing search results: ${error}`); 260 | // return the original response if we can't parse the results 261 | return response; 262 | } 263 | } 264 | } 265 | return response; 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Hub MCP Server 2 | [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/docker/hub-mcp)](https://archestra.ai/mcp-catalog/docker__hub-mcp) 3 | 4 | The Docker Hub MCP Server is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that interfaces with Docker Hub APIs to make them accessible to LLMs, enabling intelligent content discovery and repository management. 5 | 6 | Developers building with containers, especially in AI and LLM-powered workflows, often face inadequate context across the vast landscape of Docker Hub images. As a result, LLMs struggle to recommend the right images, and developers lose time manually searching instead of building. 7 | 8 |

9 | Demo 13 |

14 | 15 | ### Use Cases 16 | 17 | - AI-powered image recommendations - LLMs access real-time Docker Hub data for accurate container image suggestions. 18 | - Enhanced content discovery - AI tools help developers find the right images faster. 19 | - Simplified Hub workflows - Manage Docker repositories and images using natural language. 20 | 21 | ## Prerequisites 22 | 23 | - [Docker](https://docs.docker.com/get-docker/) installed 24 | - [Node.js](https://nodejs.org/) (version 22+) 25 | - [Optional] A [Docker Personal Access Token (PAT)](https://docs.docker.com/security/for-developers/access-tokens/) with appropriate permissions 26 | 27 | ## Setup 28 | 29 | 1. **Build** 30 | 31 | ```bash 32 | npm install 33 | npm run build 34 | ``` 35 | 36 | 2. **Run** 37 | 38 | ```bash 39 | npm start -- [--transport=http|stdio] [--port=3000] 40 | ``` 41 | 42 | - Default args: 43 | - `transport`: Choose between `http` or `stdio` (default: `stdio`) 44 | - `port=3000` 45 | This starts the server with default settings and can only access public Docker Hub content. 46 | 47 | ### Run in inspector [Optional] 48 | 49 | The MCP Inspector provides a web interface to test your server: 50 | 51 | ``` 52 | npx @modelcontextprotocol/inspector node dist/index.js [--transport=http|stdio] [--port=3000] 53 | ``` 54 | 55 | ## Authenticate with docker 56 | 57 | By default this MCP server can only query public content on Docker Hub. In order to manage your repositories you need to provide authentication. 58 | 59 | ### Run with authentication 60 | 61 | ``` 62 | HUB_PAT_TOKEN= npm start -- [--username=] 63 | ``` 64 | 65 | ### Run in inspector [Optional] 66 | 67 | ``` 68 | HUB_PAT_TOKEN= npx @modelcontextprotocol/inspector node dist/index.js[--username=] 69 | ``` 70 | ## Usage in Docker Ask Gordon 71 | You can configure Gordon to be a host that can interact with the Docker Hub MCP server. 72 | 73 | ### Gordon Setup 74 | 75 | [Ask Gordon](https://docs.docker.com/ai/gordon/) is your personal AI assistant embedded in Docker Desktop and the Docker CLI. It's designed to streamline your workflow and help you make the most of the Docker ecosystem. 76 | 77 | You can configure Gordon to be a client that can interact with the Docker Hub MCP server. 78 | 79 | 1. Create the [`gordon-mcp.yml` file](https://docs.docker.com/ai/gordon/mcp/yaml/) file in your working directory. 80 | 2. Replace environment variables in the `gordon-mcp.yml` with your Docker Hub username and a PAT token. 81 | 82 | ``` 83 | services: 84 | hub: 85 | image: hub 86 | environment: 87 | - HUB_PAT_TOKEN= 88 | command: 89 | - --username= 90 | ``` 91 | 92 | 2. Run `docker build -t hub .` 93 | 3. Run `docker ai` 94 | 95 | ## Usage in other MCP Clients 96 | 97 | ### Usage with Claude Desktop 98 | 99 | > NOTE: Make sure you have already built the application as mentioned in Step 1. 100 | 101 | 1. Add the Docker Hub MCP Server configuration to your `claude_desktop_config.json`: 102 | 103 | > NOTE: if you are using [nvm](https://github.com/nvm-sh/nvm) to manage node versions, you should put the node binary path in the `command` property. This ensure MCP server runs with the right node version. You can find your binary path by running `which node` in your shell 104 | 105 | #### For public repositories only: 106 | 107 | - `/FULL/PATH/TO/YOUR/docker-hub-mcp-server` - The complete path to where you cloned this repository 108 | 109 | ```json 110 | { 111 | "mcpServers": { 112 | "docker-hub": { 113 | "command": "node", // or absoulute binary path 114 | "args": ["/FULL/PATH/TO/YOUR/docker-hub-mcp-server/dist/index.js", "--transport=stdio"] 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | #### For authenticated access (recommended): 121 | 122 | Replace the following values: 123 | 124 | - `YOUR_DOCKER_HUB_USERNAME` - Your Docker Hub username 125 | - `YOUR_DOCKER_HUB_PERSONAL_ACCESS_TOKEN` - Your Docker Hub Personal Access Token 126 | - `/FULL/PATH/TO/YOUR/docker-hub-mcp-server` - The complete path to where you cloned this 127 | 128 | ```json 129 | { 130 | "mcpServers": { 131 | "docker-hub": { 132 | "command": "node", 133 | "args": [ 134 | "/FULL/PATH/TO/YOUR/docker-hub-mcp-server/dist/index.js", 135 | "--transport=stdio", 136 | "--username=YOUR_DOCKER_HUB_USERNAME" 137 | ], 138 | "env": { 139 | "HUB_PAT_TOKEN": "YOUR_DOCKER_HUB_PERSONAL_ACCESS_TOKEN" 140 | } 141 | } 142 | } 143 | } 144 | ``` 145 | 146 | 2. Save the configuration file and completely restart Claude Desktop for the changes to take effect. 147 | 148 | ## Usage with VS Code 149 | 150 | 1. Add the Docker Hub MCP Server configuration to your User Settings (JSON) file in VS Code. You can do this by opening the `Command Palette` and typing `Preferences: Open User Settings (JSON)`. 151 | 152 | #### For public repositories only: 153 | 154 | - `/FULL/PATH/TO/YOUR/docker-hub-mcp-server` - The complete path to where you cloned this repository 155 | 156 | ```json 157 | { 158 | "mcpServers": { 159 | "docker-hub": { 160 | "command": "node", 161 | "args": ["/FULL/PATH/TO/YOUR/docker-hub-mcp-server/dist/index.js", "--transport=stdio"] 162 | } 163 | } 164 | } 165 | ``` 166 | 167 | #### For authenticated access (recommended): 168 | 169 | Replace the following values: 170 | 171 | - `YOUR_DOCKER_HUB_USERNAME` - Your Docker Hub username 172 | - `YOUR_DOCKER_HUB_PERSONAL_ACCESS_TOKEN` - Your Docker Hub Personal Access Token 173 | - `/FULL/PATH/TO/YOUR/docker-hub-mcp-server` - The complete path to where you cloned this 174 | 175 | ```json 176 | { 177 | "mcpServers": { 178 | "docker-hub": { 179 | "command": "node", 180 | "args": [ 181 | "/FULL/PATH/TO/YOUR/docker-hub-mcp-server/dist/index.js", 182 | "--transport=stdio", 183 | "--username=YOUR_DOCKER_HUB_USERNAME" 184 | ], 185 | "env": { 186 | "HUB_PAT_TOKEN": "YOUR_DOCKER_HUB_PERSONAL_ACCESS_TOKEN" 187 | } 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | 2. Open the `Command Palette` and type `MCP: List Servers`. 194 | 3. Select `docker-hub` and select `Start Server`. 195 | 196 | ## Task Examples 197 | 198 | ### Finding images 199 | 200 | ```console 201 | # Search for official images 202 | $ docker ai "Search for official nginx images on Docker Hub" 203 | 204 | # Search for lightweight images to reduce deployment size and improve performance 205 | $ docker ai "Search for minimal Node.js images with small footprint" 206 | 207 | # Get the most recent tag of a base image 208 | $ docker ai "Show me the latest tag details for go" 209 | 210 | # Find a production-ready database with enterprise features and reliability 211 | $ docker ai "Search for production ready database images" 212 | 213 | # Compare Ubuntu versions to choose the right one for my project 214 | $ docker ai "Help me find the right Ubuntu version for my project" 215 | ``` 216 | 217 | ### Repository Management 218 | 219 | ```console 220 | # Create a repository 221 | $ docker ai "Create a repository in my namespace" 222 | 223 | # List all repositories in my namespace 224 | $ docker ai "List all repositories in my namespace" 225 | 226 | # Find the largest repository in my namespace 227 | $ docker ai "Which of my repositories takes up the most space?" 228 | 229 | # Find repositories that haven't been updated recently 230 | $ docker ai "Which of my repositories haven't had any pushes in the last 60 days?" 231 | 232 | # Find which repositories are currently active and being used 233 | $ docker ai "Show me my most recently updated repositories" 234 | 235 | # Get details about a repository 236 | $ docker ai "Show me information about my '' repository" 237 | ``` 238 | 239 | ### Pull/Push Images 240 | 241 | ```console 242 | # Pull latest PostgreSQL version 243 | $ docker ai "Pull the latest postgres image" 244 | 245 | # Push image to your Docker Hub repository 246 | $ docker ai "Push my to my repository" 247 | ``` 248 | 249 | ### Tag Management 250 | 251 | ```console 252 | # List all tags for a repository 253 | $ $ docker ai "Show me all tags for my '' repository" 254 | 255 | # Find the most recently pushed tag 256 | $ docker ai "What's the most recent tag pushed to my '' repository?" 257 | 258 | # List tags with architecture filtering 259 | $ docker ai "List tags for in the '' repository that support amd64 architecture" 260 | 261 | # Get detailed information about a specific tag 262 | $ docker ai "Show me details about the '' tag in the '' repository" 263 | 264 | # Check if a specific tag exists 265 | $ docker ai "Check if version 'v1.2.0' exists for my 'my-web-app' repository" 266 | ``` 267 | 268 | ### Docker Hardened Images 269 | 270 | ```console 271 | # List available hardened images 272 | $ docker ai "What is the most secure image I can use to run a node.js application?" 273 | 274 | # Convert Dockerfile to use a hardened image 275 | $ docker ai "Can you help me update my Dockerfile to use a docker hardened image instead of the current one" 276 | ``` 277 | 278 | ## Tools 279 | 280 | ### Search 281 | 282 | - **search** - Search repositories and content using Search V4 API 283 | - `query`: Search query parameter (string, required) 284 | - `architectures`: Filter on architectures (string, optional) 285 | - `badges`: Filter by image content type badges (string, optional) 286 | - `categories`: Filter on categories (string, optional) 287 | - `extension_reviewed`: Filter on reviewed extensions (boolean, optional) 288 | - `from`: Number of documents to skip for pagination (number, optional) 289 | - `images`: Filter on image names (string, optional) 290 | - `operating_systems`: Filter on operating systems (string, optional) 291 | - `order`: Change the ordering of results (string, optional) 292 | - `size`: Maximum number of results to return (number, optional) 293 | - `sort`: Sort results by search field (string, optional) 294 | - `type`: Filter on repository content type (string, optional) 295 | 296 | ### Namespace Management 297 | 298 | - **get_namespaces** - Get list of namespaces the user is a member of 299 | - `page`: Page number for pagination (string, optional) 300 | - `page_size`: Number of items per page (string, optional) 301 | 302 | ### Repository Management 303 | 304 | - **list_repositories_by_namespace** - List all repositories under the provided namespace 305 | - `namespace`: Repository namespace (string, required) 306 | - `content_types`: Comma-delimited list of content types (string, optional) 307 | - `media_types`: Comma-delimited list of media types (string, optional) 308 | - `name`: Search by repository name (string, optional) 309 | - `ordering`: Sort order (string, optional) 310 | - `page`: Page number (number, optional) 311 | - `page_size`: Number of items per page (number, optional) 312 | 313 | - **get_repository_info** - Get information about a repository 314 | - `namespace`: Repository namespace (string, required) 315 | - `repository`: Repository name (string, required) 316 | 317 | - **check_repository** - Check if a repository exists 318 | - `namespace`: Repository namespace (string, required) 319 | - `repository`: Repository name (string, required) 320 | 321 | - **check_repository_tag** - Check if a specific tag exists in a repository 322 | - `namespace`: Repository namespace (string, required) 323 | - `repository`: Repository name (string, required) 324 | - `tag`: Tag name (string, required) 325 | 326 | - **create_repository** - Create a new repository in the provided namespace 327 | - `namespace`: Repository namespace (string, required) 328 | - `body`: Request body data (object, optional) 329 | 330 | - **update_repository_info** - Update repository information 331 | - `namespace`: Repository namespace (string, required) 332 | - `repository`: Repository name (string, required) 333 | - `body`: Request body data (object, optional) 334 | 335 | ### Tag Management 336 | 337 | - **list_repository_tags** - List all tags for a repository 338 | - `namespace`: Repository namespace (string, required) 339 | - `repository`: Repository name (string, required) 340 | - `architecture`: Filter by architecture (string, optional) 341 | - `os`: Filter by operating system (string, optional) 342 | - `page`: Page number (number, optional) 343 | - `page_size`: Number of items per page (number, optional) 344 | - **read_repository_tag** - Get details of a specific repository tag 345 | - `namespace`: Repository namespace (string, required) 346 | - `repository`: Repository name (string, required) 347 | - `tag`: Tag name (string, required) 348 | 349 | ### Hardened Images 350 | 351 | - **docker_hardened_images** - Query for mirrored Docker Hardened Images (DHI) in the namespace 352 | - `namespace`: The namespace to query for mirrored hardened repositories (string, optional) 353 | 354 | ## Licensing 355 | 356 | [docker/hub-mcp](https://github.com/docker/hub-mcp) is licensed under the Apache License, Version 2.0. See 357 | [LICENSE](https://github.com/docker/docker/blob/master/LICENSE) for the full 358 | license text. 359 | -------------------------------------------------------------------------------- /src/repos.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Docker Hub MCP Server authors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; 18 | import { Asset, AssetConfig } from './asset'; 19 | import { z } from 'zod'; 20 | import { createPaginatedResponseSchema } from './types'; 21 | import { CallToolResult } from '@modelcontextprotocol/sdk/types'; 22 | import { logger } from './logger'; 23 | 24 | //#region Types 25 | // all items in the types are optional and nullable because structured content is always evaluated even when an error occurs. 26 | // See https://github.com/modelcontextprotocol/typescript-sdk/issues/654 27 | const Repository = z.object({ 28 | name: z.string().optional().nullable().describe('The name of the repository'), 29 | namespace: z.string().optional().nullable().describe('The namespace of the repository'), 30 | repository_type: z 31 | .nativeEnum({ 0: 'image', 1: 'docker engine plugin' }) 32 | .nullable() 33 | .optional() 34 | .describe('The type of the repository'), 35 | full_description: z 36 | .string() 37 | .nullable() 38 | .optional() 39 | .describe('The full description of the repository'), 40 | immutable_tags_settings: z 41 | .object({ 42 | enabled: z.boolean().describe('Whether the repository has immutable tags'), 43 | rules: z.array(z.string()).describe('The rules of the immutable tags'), 44 | }) 45 | .optional() 46 | .nullable() 47 | .describe('The immutable tags settings of the repository'), 48 | is_private: z.boolean().optional().nullable().describe('Whether the repository is private'), 49 | status: z.number().optional().nullable().describe('The status of the repository'), 50 | status_description: z 51 | .string() 52 | .optional() 53 | .nullable() 54 | .describe('The status description of the repository'), 55 | description: z.string().optional().nullable().describe('The description of the repository'), 56 | star_count: z.number().optional().nullable().describe('The number of stars the repository has'), 57 | pull_count: z.number().optional().nullable().describe('The number of pulls the repository has'), 58 | last_updated: z 59 | .string() 60 | .optional() 61 | .nullable() 62 | .describe('The last updated date of the repository'), 63 | last_modified: z 64 | .string() 65 | .nullable() 66 | .optional() 67 | .describe('The last modified date of the repository'), 68 | date_registered: z 69 | .string() 70 | .optional() 71 | .nullable() 72 | .describe('The date the repository was registered'), 73 | affiliation: z.string().optional().nullable().describe('The affiliation of the repository'), 74 | media_types: z 75 | .array(z.string()) 76 | .optional() 77 | .nullable() 78 | .describe('The media types of the repository'), 79 | content_types: z 80 | .array(z.string()) 81 | .optional() 82 | .nullable() 83 | .describe('The content types of the repository'), 84 | categories: z 85 | .array( 86 | z.object({ 87 | name: z.string().describe('The name of the category'), 88 | slug: z.string().describe('The slug of the category in search engine'), 89 | }) 90 | ) 91 | .optional() 92 | .nullable() 93 | .describe('The categories of the repository'), 94 | storage_size: z 95 | .number() 96 | .nullable() 97 | .optional() 98 | .nullable() 99 | .describe('The storage size of the repository'), 100 | user: z.string().optional().nullable().describe('The user of the repository'), 101 | hub_user: z.string().optional().nullable().describe('The repository username on hub'), 102 | has_starred: z 103 | .boolean() 104 | .optional() 105 | .nullable() 106 | .describe('Whether the user has starred the repository'), 107 | is_automated: z.boolean().optional().nullable().describe('Whether the repository is automated'), 108 | collaborator_count: z 109 | .number() 110 | .optional() 111 | .nullable() 112 | .describe( 113 | "The number of collaborators on the repository. Only valid when repository_type is 'User'" 114 | ), 115 | permissions: z 116 | .object({ 117 | read: z.boolean().describe('if user can read and pull from repository'), 118 | write: z.boolean().describe('if user can update and push to repository'), 119 | admin: z.boolean().describe('if user is an admin of the repository'), 120 | }) 121 | .optional() 122 | .nullable(), 123 | source: z.string().optional().nullable().describe('The source of the repository'), 124 | error: z.string().optional().nullable(), 125 | }); 126 | 127 | const CreateRepositoryRequest = z.object({ 128 | namespace: z.string().describe('The namespace of the repository. Required.'), 129 | name: z 130 | .string() 131 | .default('') 132 | .describe( 133 | 'The name of the repository (required). Must contain a combination of alphanumeric characters and may contain the special characters ., _, or -. Letters must be lowercase.' 134 | ), 135 | description: z.string().optional().describe('The description of the repository'), 136 | is_private: z.boolean().optional().describe('Whether the repository is private'), 137 | full_description: z 138 | .string() 139 | .max(25000) 140 | .optional() 141 | .describe('A detailed description of the repository'), 142 | registry: z.string().optional().describe('The registry to create the repository in'), 143 | }); 144 | 145 | const repositoryPaginatedResponseSchema = createPaginatedResponseSchema(Repository); 146 | export type RepositoryPaginatedResponse = z.infer; 147 | 148 | const RepositoryTag = z.object({ 149 | id: z.number().optional().nullable().describe('The tag ID'), 150 | images: z 151 | .array( 152 | z.object({ 153 | architecture: z.string().describe('The architecture of the tag'), 154 | features: z.string().describe('The features of the tag'), 155 | variant: z.string().optional().nullable().describe('The variant of the tag'), 156 | digest: z.string().nullable().describe('image layer digest'), 157 | layers: z 158 | .array( 159 | z.object({ 160 | digest: z.string().describe('The digest of the layer'), 161 | size: z.number().describe('The size of the layer'), 162 | instruction: z.string().describe('Dockerfile instruction'), 163 | }) 164 | ) 165 | .optional(), 166 | os: z.string().nullable().describe('operating system of the tagged image'), 167 | os_features: z 168 | .string() 169 | .nullable() 170 | .describe('features of the operating system of the tagged image'), 171 | os_version: z 172 | .string() 173 | .nullable() 174 | .describe('version of the operating system of the tagged image'), 175 | size: z.number().describe('size of the image'), 176 | status: z.enum(['active', 'inactive']).describe('status of the image'), 177 | last_pulled: z.string().nullable().describe('datetime of last pull'), 178 | last_pushed: z.string().nullable().describe('datetime of last push'), 179 | }) 180 | ) 181 | .optional() 182 | .nullable(), 183 | creator: z.number().optional().nullable().describe('ID of the user that pushed the tag'), 184 | last_updated: z.string().optional().nullable().describe('The last updated date of the tag'), 185 | last_updater: z 186 | .number() 187 | .optional() 188 | .nullable() 189 | .describe('ID of the last user that updated the tag'), 190 | last_updater_username: z 191 | .string() 192 | .optional() 193 | .nullable() 194 | .describe('Hub username of the user that updated the tag'), 195 | name: z.string().optional().nullable().describe('The name of the tag'), 196 | repository: z.number().optional().nullable().describe('The repository ID'), 197 | full_size: z 198 | .number() 199 | .optional() 200 | .nullable() 201 | .describe('compressed size (sum of all layers) of the tagged image'), 202 | v2: z.boolean().optional().nullable().describe('Repository API version'), 203 | tag_status: z 204 | .enum(['active', 'inactive']) 205 | .optional() 206 | .nullable() 207 | .describe('whether a tag has been pushed to or pulled in the past month'), 208 | tag_last_pulled: z.string().optional().nullable().describe('datetime of last pull'), 209 | tag_last_pushed: z.string().optional().nullable().describe('datetime of last push'), 210 | media_type: z.string().optional().nullable().describe('media type of this tagged artifact'), 211 | content_type: z 212 | .enum(['image', 'plugin', 'helm', 'volume', 'wasm', 'compose', 'unrecognized', 'model']) 213 | .optional() 214 | .nullable() 215 | .describe( 216 | "Content type of a tagged artifact based on it's media type. unrecognized means the media type is unrecognized by Docker Hub." 217 | ), 218 | digest: z.string().optional().nullable().describe('The digest of the tag'), 219 | error: z.string().optional().nullable(), 220 | }); 221 | const repositoryTagPaginatedResponseSchema = createPaginatedResponseSchema(RepositoryTag); 222 | export type RepositoryTagPaginatedResponse = z.infer; 223 | //#endregion 224 | 225 | export class Repos extends Asset { 226 | constructor( 227 | private server: McpServer, 228 | config: AssetConfig 229 | ) { 230 | super(config); 231 | } 232 | 233 | RegisterTools(): void { 234 | // List Repositories by Namespace 235 | this.tools.set( 236 | 'listRepositoriesByNamespace', 237 | this.server.registerTool( 238 | 'listRepositoriesByNamespace', 239 | { 240 | description: 'List paginated repositories by namespace', 241 | inputSchema: { 242 | namespace: z.string().describe('The namespace to list repositories from'), 243 | page: z 244 | .number() 245 | .optional() 246 | .describe('The page number to list repositories from'), 247 | page_size: z 248 | .number() 249 | .optional() 250 | .describe('The page size to list repositories from'), 251 | ordering: z 252 | .enum([ 253 | 'last_updated', 254 | '-last_updated', 255 | 'name', 256 | '-name', 257 | 'pull_count', 258 | '-pull_count', 259 | ]) 260 | .optional() 261 | .describe( 262 | 'The ordering of the repositories. Use "-" to reverse the ordering. For example, "last_updated" will order the repositories by last updated in descending order while "-last_updated" will order the repositories by last updated in ascending order.' 263 | ), 264 | media_types: z 265 | .string() 266 | .optional() 267 | .default('') 268 | .describe( 269 | 'Comma-delimited list of media types. Only repositories containing one or more artifacts with one of these media types will be returned. Default is empty to get all repositories.' 270 | ), 271 | content_types: z 272 | .string() 273 | .optional() 274 | .default('') 275 | .describe( 276 | 'Comma-delimited list of content types. Only repositories containing one or more artifacts with one of these content types will be returned. Default is empty to get all repositories.' 277 | ), 278 | }, 279 | outputSchema: repositoryPaginatedResponseSchema.shape, 280 | annotations: { 281 | title: 'List Repositories by Namespace', 282 | }, 283 | title: 'List Repositories by Organisation (namespace)', 284 | }, 285 | this.listRepositoriesByNamespace.bind(this) 286 | ) 287 | ); 288 | // Create Repository 289 | this.tools.set( 290 | 'createRepository', 291 | this.server.registerTool( 292 | 'createRepository', 293 | { 294 | description: 295 | 'Create a new repository in the given namespace. You MUST ask the user for the repository name and if the repository has to be public or private. Can optionally pass a description.\nIMPORTANT: Before calling this tool, you must ensure you have:\n The repository name (name).', 296 | inputSchema: CreateRepositoryRequest.shape, 297 | outputSchema: Repository.shape, 298 | annotations: { 299 | title: 'Create Repository in namespace', 300 | }, 301 | title: 'Create a repository in organisation (namespace) or personal namespace', 302 | }, 303 | this.createRepository.bind(this) 304 | ) 305 | ); 306 | // Get Repository Info 307 | this.tools.set( 308 | 'getRepositoryInfo', 309 | this.server.registerTool( 310 | 'getRepositoryInfo', 311 | { 312 | description: 'Get the details of a repository in the given namespace.', 313 | inputSchema: z.object({ 314 | namespace: z 315 | .string() 316 | .describe( 317 | 'The namespace of the repository (required). If not provided the `library` namespace will be used for official images.' 318 | ), 319 | repository: z.string().describe('The repository name (required)'), 320 | }).shape, 321 | outputSchema: Repository.shape, 322 | annotations: { 323 | title: 'Get Repository Info', 324 | }, 325 | title: 'Get Repository Details', 326 | }, 327 | this.getRepositoryInfo.bind(this) 328 | ) 329 | ); 330 | 331 | // Update Repository Info 332 | this.tools.set( 333 | 'updateRepositoryInfo', 334 | this.server.registerTool( 335 | 'updateRepositoryInfo', 336 | { 337 | description: 338 | 'Update the details of a repository in the given namespace. Description, overview and status are the only fields that can be updated. While description and overview changes are fine, a status change is a dangerous operation so the user must explicitly ask for it.', 339 | inputSchema: z.object({ 340 | namespace: z 341 | .string() 342 | .describe('The namespace of the repository (required)'), 343 | repository: z.string().describe('The repository name (required)'), 344 | description: z 345 | .string() 346 | .optional() 347 | .describe( 348 | 'The description of the repository. If user asks for updating the description of the repository, this is the field that should be updated.' 349 | ), 350 | full_description: z 351 | .string() 352 | .max(25000) 353 | .optional() 354 | .describe( 355 | 'The full description (overview)of the repository. If user asks for updating the full description or the overview of the repository, this is the field that should be updated. ' 356 | ), 357 | status: z 358 | .enum(['active', 'inactive']) 359 | .optional() 360 | .nullable() 361 | .describe( 362 | 'The status of the repository. If user asks for updating the status of the repository, this is the field that should be updated. This is a dangerous operation and should be done with caution so user must be prompted to confirm the operation. Valid status are `active` (1) and `inactive` (0). Normally do not update the status if it is not strictly required by the user. It is not possible to change an `inactive` repository to `active` if it has no images.' 363 | ), 364 | }).shape, 365 | outputSchema: Repository.shape, 366 | annotations: { 367 | title: 'Get Repository Info', 368 | }, 369 | title: 'Update Repository Details', 370 | }, 371 | this.updateRepositoryInfo.bind(this) 372 | ) 373 | ); 374 | 375 | // Check Repository Exists 376 | this.tools.set( 377 | 'checkRepository', 378 | this.server.registerTool( 379 | 'checkRepository', 380 | { 381 | description: 'Check if a repository exists in the given namespace.', 382 | inputSchema: z.object({ namespace: z.string(), repository: z.string() }).shape, 383 | annotations: { 384 | title: 'Check Repository Exists', 385 | }, 386 | title: 'Check Repository Exists', 387 | }, 388 | this.checkRepository.bind(this) 389 | ) 390 | ); 391 | 392 | // List Repository Tags 393 | this.tools.set( 394 | 'listRepositoryTags', 395 | this.server.registerTool( 396 | 'listRepositoryTags', 397 | { 398 | description: 'List paginated tags by repository', 399 | inputSchema: z.object({ 400 | namespace: z 401 | .string() 402 | .optional() 403 | .describe( 404 | "The namespace of the repository. If not provided the 'library' namespace will be used for official images." 405 | ), 406 | repository: z.string().describe('The repository to list tags from'), 407 | page: z.number().optional().describe('The page number to list tags from'), 408 | page_size: z 409 | .number() 410 | .optional() 411 | .describe('The page size to list tags from'), 412 | architecture: z 413 | .string() 414 | .optional() 415 | .describe( 416 | 'The architecture to list tags from. If not provided, all architectures will be listed.' 417 | ), 418 | os: z 419 | .string() 420 | .optional() 421 | .describe( 422 | 'The operating system to list tags from. If not provided, all operating systems will be listed.' 423 | ), 424 | }).shape, 425 | outputSchema: repositoryTagPaginatedResponseSchema.shape, 426 | annotations: { 427 | title: 'List Repository Tags', 428 | }, 429 | title: 'List tags by repository', 430 | }, 431 | this.listRepositoryTags.bind(this) 432 | ) 433 | ); 434 | 435 | // Get Repository Tag 436 | this.tools.set( 437 | 'getRepositoryTag', 438 | this.server.registerTool( 439 | 'getRepositoryTag', 440 | { 441 | description: 442 | 'Get the details of a tag in a repository. It can be use to show the latest tag details for example.', 443 | inputSchema: z.object({ 444 | namespace: z.string(), 445 | repository: z.string(), 446 | tag: z.string(), 447 | }).shape, 448 | outputSchema: RepositoryTag.shape, 449 | annotations: { 450 | title: 'Get Repository Tag', 451 | }, 452 | title: 'Get Repository Tag Details', 453 | }, 454 | this.getRepositoryTag.bind(this) 455 | ) 456 | ); 457 | // Check Repository Tag 458 | this.tools.set( 459 | 'checkRepositoryTag', 460 | this.server.registerTool( 461 | 'checkRepositoryTag', 462 | { 463 | description: 'Check if a tag exists in a repository', 464 | inputSchema: z.object({ 465 | namespace: z.string(), 466 | repository: z.string(), 467 | tag: z.string(), 468 | }).shape, 469 | annotations: { 470 | title: 'Check Repository Tag', 471 | }, 472 | title: 'Check Repository Tag Exists', 473 | }, 474 | this.checkRepositoryTag.bind(this) 475 | ) 476 | ); 477 | } 478 | 479 | private async listRepositoriesByNamespace({ 480 | namespace, 481 | page, 482 | page_size, 483 | ordering, 484 | media_types, 485 | content_types, 486 | }: { 487 | namespace: string; 488 | page?: number; 489 | page_size?: number; 490 | ordering?: string; 491 | media_types?: string; 492 | content_types?: string; 493 | }): Promise { 494 | if (!namespace) { 495 | throw new Error('Namespace is required'); 496 | } 497 | if (!page) { 498 | page = 1; 499 | } 500 | if (!page_size) { 501 | page_size = 10; 502 | } 503 | let url = `${this.config.host}/namespaces/${namespace}/repositories?page=${page}&page_size=${page_size}`; 504 | if (ordering) { 505 | url += `&ordering=${ordering}`; 506 | } 507 | if (media_types) { 508 | url += `&media_types=${media_types}`; 509 | } 510 | if (content_types) { 511 | url += `&content_types=${content_types}`; 512 | } 513 | 514 | return this.callAPI( 515 | url, 516 | { method: 'GET' }, 517 | `Here are the repositories for ${namespace}: :response`, 518 | `Error getting repositories for ${namespace}` 519 | ); 520 | } 521 | 522 | private async listRepositoryTags({ 523 | repository, 524 | namespace, 525 | page, 526 | page_size, 527 | architecture, 528 | os, 529 | }: { 530 | repository: string; 531 | namespace?: string; 532 | page?: number; 533 | page_size?: number; 534 | architecture?: string; 535 | os?: string; 536 | }): Promise { 537 | if (!namespace) { 538 | namespace = 'library'; 539 | } 540 | if (!page) { 541 | page = 1; 542 | } 543 | if (!page_size) { 544 | page_size = 10; 545 | } 546 | let url = `${this.config.host}/namespaces/${namespace}/repositories/${repository}/tags`; 547 | const params: Record = {}; 548 | if (architecture) { 549 | params.architecture = architecture; 550 | } 551 | if (os) { 552 | params.os = os; 553 | } 554 | if (Object.keys(params).length > 0) { 555 | url += `?${new URLSearchParams(params).toString()}`; 556 | } 557 | 558 | return this.callAPI( 559 | url, 560 | { method: 'GET' }, 561 | `Here are the tags for ${namespace}/${repository}: :response`, 562 | `Error getting tags for ${namespace}/${repository}. Maybe you did not provide the right namespace or repository name.` 563 | ); 564 | } 565 | 566 | private async createRepository( 567 | request: z.infer 568 | ): Promise { 569 | // sometimes the mcp client tries to pass a default repository name. Fail in this case. 570 | if (!request.name || request.name === 'new-repository') { 571 | logger.error('Repository name is required.'); 572 | throw new Error('Repository name is required.'); 573 | } 574 | const url = `${this.config.host}/namespaces/${request.namespace}/repositories`; 575 | return this.callAPI>( 576 | url, 577 | { method: 'POST', body: JSON.stringify(request) }, 578 | `Repository ${request.name} created successfully. You can access it at https://hub.docker.com/r/${request.namespace}/${request.name}. \n :response`, 579 | `Error creating repository ${request.name}` 580 | ); 581 | } 582 | 583 | private async getRepositoryInfo({ 584 | namespace, 585 | repository, 586 | }: { 587 | namespace: string; 588 | repository: string; 589 | }): Promise { 590 | if (!namespace || !repository) { 591 | logger.error('Namespace and repository name are required'); 592 | throw new Error('Namespace and repository name are required'); 593 | } 594 | logger.info(`Getting info for repository ${repository} in ${namespace}`); 595 | const url = `${this.config.host}/namespaces/${namespace}/repositories/${repository}`; 596 | 597 | const response = await this.callAPI>( 598 | url, 599 | { method: 'GET' }, 600 | `Here are the details of the repository :${repository} in ${namespace}. :response`, 601 | `Error getting repository info for ${repository} in ${namespace}` 602 | ); 603 | if (namespace === 'library') { 604 | response.content.push({ 605 | type: 'text', 606 | text: `This is an official image from Docker Hub. You can access it at https://hub.docker.com/_/${repository}.\nIf you did not ask for an official image, please call this tool again and clearly specify a namespace.`, 607 | }); 608 | } 609 | return response; 610 | } 611 | 612 | private async updateRepositoryInfo({ 613 | namespace, 614 | repository, 615 | description, 616 | full_description, 617 | status, 618 | }: { 619 | namespace: string; 620 | repository: string; 621 | description?: string; 622 | full_description?: string; 623 | status?: string | null; 624 | }): Promise { 625 | const extraContent: { type: 'text'; text: string }[] = []; 626 | if (!namespace || !repository) { 627 | throw new Error('Namespace and repository name are required'); 628 | } 629 | logger.info( 630 | `Updating repository ${repository} in ${namespace} with description: ${description}, full_description: ${full_description}, status: ${status}` 631 | ); 632 | const url = `${this.config.host}/namespaces/${namespace}/repositories/${repository}`; 633 | const body: { description?: string; full_description?: string; status?: number } = {}; 634 | if (description && description !== '') { 635 | body.description = description; 636 | } 637 | if (full_description && full_description !== '') { 638 | body.full_description = full_description; 639 | } 640 | if (status !== undefined) { 641 | // get current repository info to check if a status change is needed 642 | const currentRepository = await this.getRepositoryInfo({ namespace, repository }); 643 | if (currentRepository.isError) { 644 | logger.error(`Error getting repository info for ${repository} in ${namespace}`); 645 | return { 646 | isError: true, 647 | content: [ 648 | { 649 | type: 'text', 650 | text: `Error getting repository info for ${repository} in ${namespace}`, 651 | }, 652 | ], 653 | }; 654 | } 655 | const currentStatus = ( 656 | currentRepository.structuredContent as z.infer 657 | ).status; 658 | if (currentStatus !== status) { 659 | logger.info( 660 | `Repository ${repository} in ${namespace} is currently in status ${currentStatus}. Updating to ${status}.` 661 | ); 662 | if (status === 'active') { 663 | return { 664 | isError: true, 665 | content: [ 666 | { 667 | type: 'text', 668 | text: `Repository ${repository} in ${namespace} is currently inactive. It is not possible to change an inactive repository to active if it has no images. If you did not ask for updating the status of the repository, please call this tool again and specifically ask for updating only the description or the overview of the repository.`, 669 | }, 670 | ], 671 | structuredContent: { 672 | error: `Repository ${repository} in ${namespace} is currently inactive. It is not possible to change an inactive repository to active if it has no images. If you did not ask for updating the status of the repository, please call this tool again and specifically ask for updating only the description or the overview of the repository.`, 673 | }, 674 | }; 675 | } 676 | body.status = status === 'active' ? 1 : 0; 677 | extraContent.push({ 678 | type: 'text', 679 | text: `Requested a status change from ${currentStatus} to ${status}. This is potentially a dangerous operation and should be done with caution. If you are not sure, please go on Docker Hub and revert the status manually.\nhttps://hub.docker.com/r/${namespace}/${repository}`, 680 | }); 681 | } 682 | } 683 | const response = await this.callAPI>( 684 | url, 685 | { method: 'PATCH', body: JSON.stringify(body) }, 686 | `Repository ${repository} updated successfully. :response`, 687 | `Error updating repository ${repository}` 688 | ); 689 | if (extraContent.length > 0) { 690 | response.content = [...response.content, ...extraContent]; 691 | } 692 | return response; 693 | } 694 | 695 | private async getRepositoryTag({ 696 | namespace, 697 | repository, 698 | tag, 699 | }: { 700 | namespace: string; 701 | repository: string; 702 | tag: string; 703 | }): Promise { 704 | if (!namespace || !repository || !tag) { 705 | throw new Error('Namespace, repository name and tag are required'); 706 | } 707 | const url = `${this.config.host}/namespaces/${namespace}/repositories/${repository}/tags/${tag}`; 708 | 709 | return this.callAPI>( 710 | url, 711 | { method: 'GET' }, 712 | `Here are the details of the tag :${tag} in ${namespace}/${repository}. :response`, 713 | `Error getting repository info for ${repository} in ${namespace}` 714 | ); 715 | } 716 | 717 | private async checkRepository({ 718 | namespace, 719 | repository, 720 | }: { 721 | namespace: string; 722 | repository: string; 723 | }): Promise { 724 | if (!namespace || !repository) { 725 | throw new Error('Namespace and repository name are required'); 726 | } 727 | const url = `${this.config.host}/namespaces/${namespace}/repositories/${repository}`; 728 | 729 | return this.callAPI( 730 | url, 731 | { method: 'HEAD' }, 732 | `Repository :${repository} in ${namespace} exists.`, 733 | `Repository :${repository} in ${namespace} does not exist.` 734 | ); 735 | } 736 | 737 | private async checkRepositoryTag({ 738 | namespace, 739 | repository, 740 | tag, 741 | }: { 742 | namespace: string; 743 | repository: string; 744 | tag: string; 745 | }): Promise { 746 | if (!namespace || !repository || !tag) { 747 | throw new Error('Namespace, repository name and tag are required'); 748 | } 749 | const url = `${this.config.host}/namespaces/${namespace}/repositories/${repository}/tags/${tag}`; 750 | 751 | return this.callAPI( 752 | url, 753 | { method: 'HEAD' }, 754 | `Repository :${repository} in ${namespace} contains tag ${tag}.`, 755 | `Repository :${repository} in ${namespace} does not contain tag ${tag}.` 756 | ); 757 | } 758 | } 759 | --------------------------------------------------------------------------------