diff --git a/contrib/claude/mcp.md b/contrib/claude/mcp.md index 718494734..dbb848f32 100644 --- a/contrib/claude/mcp.md +++ b/contrib/claude/mcp.md @@ -25,17 +25,33 @@ go generate ./pkg/server/api/mcp/v1 ```yaml tools: - name: listThirdParties + title: List Third Parties description: List all thirdParties for the organization hints: readonly: true idempotent: true - destructive: false inputSchema: $ref: "#/components/schemas/ListThirdPartiesInput" outputSchema: $ref: "#/components/schemas/ListThirdPartiesOutput" + - name: deleteThirdParty + title: Delete Third Party + description: Delete a thirdParty + hints: + readonly: false + destructive: true + inputSchema: + $ref: "#/components/schemas/DeleteThirdPartyInput" + outputSchema: + $ref: "#/components/schemas/DeleteThirdPartyOutput" ``` +`title` is the human-readable display name (emitted as MCP `title` / +`annotations.title`). `hints.readonly` and `hints.destructive` map to +`readOnlyHint` and `destructiveHint` so clients can distinguish reads, writes, +and deletes. Every delete/remove/unlink/cancel/void tool must set +`destructive: true`. + Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension: ```yaml diff --git a/go.mod b/go.mod index c732c47a4..bb8d8b6b7 100644 --- a/go.mod +++ b/go.mod @@ -272,3 +272,5 @@ tool ( ) replace github.com/elimity-com/scim => github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3 + +replace go.probo.inc/mcpgen => ./third_party/mcpgen diff --git a/go.sum b/go.sum index 0c8968b23..5621765a5 100644 --- a/go.sum +++ b/go.sum @@ -651,8 +651,6 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619 h1:LHOdoF7kYRXFtSP97eWpF1dIf0dBLrunRLOeU/pXt9c= -go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619/go.mod h1:HunWQGqLdMocExJh4tWaX7p+uRZ9GlKvBvOXHaFW6vM= go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA= go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index fb3919ba8..eb886486a 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -12593,6 +12593,7 @@ components: tools: - name: listOrganizations + title: List Organizations description: List all organizations the user has access to hints: readonly: true @@ -12602,6 +12603,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListOrganizationsOutput" - name: listThirdParties + title: List Third Parties description: List all thirdParties for the organization hints: readonly: true @@ -12611,6 +12613,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListThirdPartiesOutput" - name: listChildThirdParties + title: List Child Third Parties description: List child third parties linked to a parent third party hints: readonly: true @@ -12620,6 +12623,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListChildThirdPartiesOutput" - name: listUsers + title: List Users description: List all users for the organization hints: readonly: true @@ -12629,6 +12633,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListUsersOutput" - name: getUser + title: Get User description: Get a user by ID (profile ID) hints: readonly: true @@ -12638,6 +12643,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetUserOutput" - name: createUser + title: Create User description: Create a new user in the organization hints: readonly: false @@ -12646,6 +12652,7 @@ tools: outputSchema: $ref: "#/components/schemas/CreateUserOutput" - name: inviteUser + title: Invite User description: Invite a user (profile) to the organization hints: readonly: false @@ -12654,6 +12661,7 @@ tools: outputSchema: $ref: "#/components/schemas/InviteUserOutput" - name: updateUser + title: Update User description: Update an existing user (profile) hints: readonly: false @@ -12662,6 +12670,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateUserOutput" - name: updateMembership + title: Update Membership description: Update a membership role hints: readonly: false @@ -12670,6 +12679,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateMembershipOutput" - name: removeUser + title: Remove User description: Remove a user from the organization hints: readonly: false @@ -12678,8 +12688,14 @@ tools: $ref: "#/components/schemas/RemoveUserInput" outputSchema: $ref: "#/components/schemas/RemoveUserOutput" +<<<<<<< HEAD - name: deactivateUser description: Deactivate a user in the organization +======= + - name: archiveUser + title: Archive User + description: Archive a user in the organization +>>>>>>> b29c4a15c (Annotate MCP tools with titles and hints) hints: readonly: false inputSchema: @@ -12687,6 +12703,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeactivateUserOutput" - name: addThirdParty + title: Add Third Party description: Add a new thirdParty to the organization hints: readonly: false @@ -12695,6 +12712,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddThirdPartyOutput" - name: updateThirdParty + title: Update Third Party description: Update an existing thirdParty hints: readonly: false @@ -12703,6 +12721,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateThirdPartyOutput" - name: listThirdPartyRiskAssessments + title: List Third Party Risk Assessments description: List all risk assessments for a thirdParty hints: readonly: true @@ -12712,6 +12731,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListThirdPartyRiskAssessmentsOutput" - name: addThirdPartyRiskAssessment + title: Add Third Party Risk Assessment description: Add a new risk assessment for a thirdParty hints: readonly: false @@ -12720,6 +12740,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddThirdPartyRiskAssessmentOutput" - name: deleteThirdParty + title: Delete Third Party description: Delete a thirdParty hints: readonly: false @@ -12729,6 +12750,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteThirdPartyOutput" - name: listThirdPartyContacts + title: List Third Party Contacts description: List all contacts for a thirdParty hints: readonly: true @@ -12738,6 +12760,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListThirdPartyContactsOutput" - name: addThirdPartyContact + title: Add Third Party Contact description: Add a new contact to a thirdParty hints: readonly: false @@ -12746,6 +12769,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddThirdPartyContactOutput" - name: updateThirdPartyContact + title: Update Third Party Contact description: Update an existing thirdParty contact hints: readonly: false @@ -12754,6 +12778,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateThirdPartyContactOutput" - name: deleteThirdPartyContact + title: Delete Third Party Contact description: Delete a thirdParty contact hints: readonly: false @@ -12763,6 +12788,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteThirdPartyContactOutput" - name: listThirdPartyServices + title: List Third Party Services description: List all services for a thirdParty hints: readonly: true @@ -12772,6 +12798,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListThirdPartyServicesOutput" - name: addThirdPartyService + title: Add Third Party Service description: Add a new service to a thirdParty hints: readonly: false @@ -12780,6 +12807,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddThirdPartyServiceOutput" - name: updateThirdPartyService + title: Update Third Party Service description: Update an existing thirdParty service hints: readonly: false @@ -12788,6 +12816,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateThirdPartyServiceOutput" - name: deleteThirdPartyService + title: Delete Third Party Service description: Delete a thirdParty service hints: readonly: false @@ -12797,6 +12826,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteThirdPartyServiceOutput" - name: vetThirdParty + title: Vet Third Party description: Start AI-powered vetting of a third party by crawling its website. Returns immediately; vetting runs in the background. hints: readonly: false @@ -12805,6 +12835,7 @@ tools: outputSchema: $ref: "#/components/schemas/VetThirdPartyOutput" - name: listRisks + title: List Risks description: List all risks for the organization hints: readonly: true @@ -12814,6 +12845,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRisksOutput" - name: getRisk + title: Get Risk description: Get a risk by ID hints: readonly: true @@ -12823,6 +12855,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskOutput" - name: addRisk + title: Add Risk description: Add a new risk to the organization hints: readonly: false @@ -12831,6 +12864,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskOutput" - name: updateRisk + title: Update Risk description: Update an existing risk hints: readonly: false @@ -12839,6 +12873,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskOutput" - name: deleteRisk + title: Delete Risk description: Delete a risk hints: readonly: false @@ -12848,6 +12883,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskOutput" - name: listMeasures + title: List Measures description: List all measures for the organization hints: readonly: true @@ -12857,6 +12893,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasuresOutput" - name: getMeasure + title: Get Measure description: Get a measure by ID hints: readonly: true @@ -12866,6 +12903,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetMeasureOutput" - name: addMeasure + title: Add Measure description: Add a new measure to the organization hints: readonly: false @@ -12874,6 +12912,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddMeasureOutput" - name: updateMeasure + title: Update Measure description: Update an existing measure hints: readonly: false @@ -12882,6 +12921,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateMeasureOutput" - name: deleteMeasure + title: Delete Measure description: Delete a measure hints: readonly: false @@ -12891,6 +12931,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteMeasureOutput" - name: listMeasureRisks + title: List Measure Risks description: List risks linked to a measure hints: readonly: true @@ -12900,6 +12941,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasureRisksOutput" - name: listMeasureControls + title: List Measure Controls description: List controls linked to a measure hints: readonly: true @@ -12909,6 +12951,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasureControlsOutput" - name: listMeasureTasks + title: List Measure Tasks description: List tasks linked to a measure hints: readonly: true @@ -12918,6 +12961,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasureTasksOutput" - name: listMeasureEvidences + title: List Measure Evidences description: List evidences linked to a measure hints: readonly: true @@ -12927,6 +12971,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasureEvidencesOutput" - name: linkMeasure + title: Link Measure description: Link a measure to a resource (control, risk, document, or third party). The resource type is determined from the resource_id GID. hints: readonly: false @@ -12935,14 +12980,17 @@ tools: outputSchema: $ref: "#/components/schemas/LinkMeasureOutput" - name: unlinkMeasure + title: Unlink Measure description: Unlink a measure from a resource (control, risk, document, or third party). The resource type is determined from the resource_id GID. hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/UnlinkMeasureInput" outputSchema: $ref: "#/components/schemas/UnlinkMeasureOutput" - name: listMeasureDocuments + title: List Measure Documents description: List documents linked to a measure hints: readonly: true @@ -12952,6 +13000,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasureDocumentsOutput" - name: listFrameworks + title: List Frameworks description: List all frameworks for the organization hints: readonly: true @@ -12961,6 +13010,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListFrameworksOutput" - name: getFramework + title: Get Framework description: Get a framework by ID hints: readonly: true @@ -12970,6 +13020,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetFrameworkOutput" - name: addFramework + title: Add Framework description: Add a new framework to the organization hints: readonly: false @@ -12978,6 +13029,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddFrameworkOutput" - name: updateFramework + title: Update Framework description: Update an existing framework hints: readonly: false @@ -12986,6 +13038,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateFrameworkOutput" - name: listAssets + title: List Assets description: List all assets for the organization hints: readonly: true @@ -12995,6 +13048,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListAssetsOutput" - name: getAsset + title: Get Asset description: Get an asset by ID hints: readonly: true @@ -13004,6 +13058,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetAssetOutput" - name: addAsset + title: Add Asset description: Add a new asset to the organization hints: readonly: false @@ -13012,6 +13067,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddAssetOutput" - name: updateAsset + title: Update Asset description: Update an existing asset hints: readonly: false @@ -13020,6 +13076,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateAssetOutput" - name: deleteAsset + title: Delete Asset description: Delete an asset hints: readonly: false @@ -13029,6 +13086,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteAssetOutput" - name: listData + title: List Data description: List all data for the organization hints: readonly: true @@ -13038,6 +13096,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListDataOutput" - name: getDatum + title: Get Datum description: Get a datum by ID hints: readonly: true @@ -13047,6 +13106,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetDatumOutput" - name: addDatum + title: Add Datum description: Add a new datum to the organization hints: readonly: false @@ -13055,6 +13115,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddDatumOutput" - name: updateDatum + title: Update Datum description: Update an existing datum hints: readonly: false @@ -13063,6 +13124,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateDatumOutput" - name: deleteDatum + title: Delete Datum description: Delete a datum hints: readonly: false @@ -13072,6 +13134,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteDatumOutput" - name: listFindings + title: List Findings description: List all findings (nonconformities, observations, exceptions) for the organization hints: readonly: true @@ -13081,6 +13144,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListFindingsOutput" - name: getFinding + title: Get Finding description: Get a finding by ID hints: readonly: true @@ -13090,6 +13154,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetFindingOutput" - name: addFinding + title: Add Finding description: Add a new finding (nonconformity, observation, or exception) to the organization hints: readonly: false @@ -13098,6 +13163,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddFindingOutput" - name: updateFinding + title: Update Finding description: Update an existing finding hints: readonly: false @@ -13106,6 +13172,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateFindingOutput" - name: deleteFinding + title: Delete Finding description: Delete a finding hints: readonly: false @@ -13115,6 +13182,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteFindingOutput" - name: linkFindingAudit + title: Link Finding Audit description: Link a finding to an audit with a reference ID hints: readonly: false @@ -13123,6 +13191,7 @@ tools: outputSchema: $ref: "#/components/schemas/LinkFindingAuditOutput" - name: unlinkFindingAudit + title: Unlink Finding Audit description: Unlink a finding from an audit hints: readonly: false @@ -13132,6 +13201,7 @@ tools: outputSchema: $ref: "#/components/schemas/UnlinkFindingAuditOutput" - name: listFindingAudits + title: List Finding Audits description: List audits linked to a finding hints: readonly: true @@ -13141,6 +13211,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListFindingAuditsOutput" - name: listObligations + title: List Obligations description: List all obligations for the organization hints: readonly: true @@ -13150,6 +13221,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListObligationsOutput" - name: getObligation + title: Get Obligation description: Get an obligation by ID hints: readonly: true @@ -13159,6 +13231,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetObligationOutput" - name: addObligation + title: Add Obligation description: Add a new obligation to the organization hints: readonly: false @@ -13167,6 +13240,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddObligationOutput" - name: updateObligation + title: Update Obligation description: Update an existing obligation hints: readonly: false @@ -13175,6 +13249,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateObligationOutput" - name: deleteObligation + title: Delete Obligation description: Delete an obligation hints: readonly: false @@ -13184,6 +13259,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteObligationOutput" - name: listProcessingActivities + title: List Processing Activities description: List all processing activities for the organization hints: readonly: true @@ -13193,6 +13269,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListProcessingActivitiesOutput" - name: getProcessingActivity + title: Get Processing Activity description: Get a processing activity by ID hints: readonly: true @@ -13202,6 +13279,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetProcessingActivityOutput" - name: addProcessingActivity + title: Add Processing Activity description: Add a new processing activity to the organization hints: readonly: false @@ -13210,6 +13288,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddProcessingActivityOutput" - name: updateProcessingActivity + title: Update Processing Activity description: Update an existing processing activity hints: readonly: false @@ -13218,14 +13297,17 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateProcessingActivityOutput" - name: deleteProcessingActivity + title: Delete Processing Activity description: Delete a processing activity hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/DeleteProcessingActivityInput" outputSchema: $ref: "#/components/schemas/DeleteProcessingActivityOutput" - name: listDataProtectionImpactAssessments + title: List Data Protection Impact Assessments description: List all data protection impact assessments (DPIAs) for the organization hints: readonly: true @@ -13235,6 +13317,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListDataProtectionImpactAssessmentsOutput" - name: getDataProtectionImpactAssessment + title: Get Data Protection Impact Assessment description: Get a data protection impact assessment (DPIA) by ID hints: readonly: true @@ -13244,6 +13327,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetDataProtectionImpactAssessmentOutput" - name: addDataProtectionImpactAssessment + title: Add Data Protection Impact Assessment description: Add a new data protection impact assessment (DPIA) for a processing activity hints: readonly: false @@ -13252,6 +13336,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddDataProtectionImpactAssessmentOutput" - name: updateDataProtectionImpactAssessment + title: Update Data Protection Impact Assessment description: Update an existing data protection impact assessment (DPIA) hints: readonly: false @@ -13260,14 +13345,17 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateDataProtectionImpactAssessmentOutput" - name: deleteDataProtectionImpactAssessment + title: Delete Data Protection Impact Assessment description: Delete a data protection impact assessment (DPIA) by ID hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/DeleteDataProtectionImpactAssessmentInput" outputSchema: $ref: "#/components/schemas/DeleteDataProtectionImpactAssessmentOutput" - name: listTransferImpactAssessments + title: List Transfer Impact Assessments description: List all transfer impact assessments (TIAs) for the organization hints: readonly: true @@ -13277,6 +13365,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListTransferImpactAssessmentsOutput" - name: getTransferImpactAssessment + title: Get Transfer Impact Assessment description: Get a transfer impact assessment (TIA) by ID hints: readonly: true @@ -13286,6 +13375,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetTransferImpactAssessmentOutput" - name: addTransferImpactAssessment + title: Add Transfer Impact Assessment description: Add a new transfer impact assessment (TIA) for a processing activity hints: readonly: false @@ -13294,6 +13384,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddTransferImpactAssessmentOutput" - name: updateTransferImpactAssessment + title: Update Transfer Impact Assessment description: Update an existing transfer impact assessment (TIA) hints: readonly: false @@ -13302,14 +13393,17 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateTransferImpactAssessmentOutput" - name: deleteTransferImpactAssessment + title: Delete Transfer Impact Assessment description: Delete a transfer impact assessment (TIA) hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/DeleteTransferImpactAssessmentInput" outputSchema: $ref: "#/components/schemas/DeleteTransferImpactAssessmentOutput" - name: listAudits + title: List Audits description: List all audits for the organization hints: readonly: true @@ -13319,6 +13413,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListAuditsOutput" - name: getAudit + title: Get Audit description: Get an audit by ID hints: readonly: true @@ -13328,6 +13423,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetAuditOutput" - name: addAudit + title: Add Audit description: Add a new audit to the organization hints: readonly: false @@ -13336,6 +13432,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddAuditOutput" - name: updateAudit + title: Update Audit description: Update an existing audit hints: readonly: false @@ -13344,6 +13441,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateAuditOutput" - name: deleteAudit + title: Delete Audit description: Delete an audit hints: readonly: false @@ -13353,6 +13451,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteAuditOutput" - name: getAuditReportUrl + title: Get Audit Report URL description: Get a presigned download URL for an audit's attached report. Returns a time-limited URL valid for 15 minutes. The audit must have an attached report. hints: readonly: true @@ -13362,6 +13461,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetAuditReportUrlOutput" - name: listControls + title: List Controls description: List all controls for the organization or framework hints: readonly: true @@ -13371,6 +13471,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListControlsOutput" - name: getControl + title: Get Control description: Get a control by ID hints: readonly: true @@ -13380,6 +13481,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetControlOutput" - name: addControl + title: Add Control description: Add a new control to a framework hints: readonly: false @@ -13388,6 +13490,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddControlOutput" - name: updateControl + title: Update Control description: Update an existing control hints: readonly: false @@ -13396,6 +13499,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateControlOutput" - name: linkControl + title: Link Control description: Link a resource to a control (measure, document, audit, or obligation). The resource type is determined from the resource_id GID. hints: readonly: false @@ -13404,14 +13508,17 @@ tools: outputSchema: $ref: "#/components/schemas/LinkControlOutput" - name: unlinkControl + title: Unlink Control description: Unlink a resource from a control (measure, document, audit, or obligation). The resource type is determined from the resource_id GID. hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/UnlinkControlInput" outputSchema: $ref: "#/components/schemas/UnlinkControlOutput" - name: listControlObligations + title: List Control Obligations description: List obligations linked to a control hints: readonly: true @@ -13421,6 +13528,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListControlObligationsOutput" - name: listControlMeasures + title: List Control Measures description: List measures linked to a control hints: readonly: true @@ -13430,6 +13538,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListControlMeasuresOutput" - name: listControlDocuments + title: List Control Documents description: List documents linked to a control hints: readonly: true @@ -13439,6 +13548,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListControlDocumentsOutput" - name: listControlAudits + title: List Control Audits description: List audits linked to a control hints: readonly: true @@ -13448,6 +13558,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListControlAuditsOutput" - name: listRiskObligations + title: List Risk Obligations description: List obligations linked to a risk hints: readonly: true @@ -13457,6 +13568,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskObligationsOutput" - name: linkRisk + title: Link Risk description: Link a risk to a resource (document, measure, or obligation). The resource type is determined from the resource_id GID. hints: readonly: false @@ -13465,14 +13577,17 @@ tools: outputSchema: $ref: "#/components/schemas/LinkRiskOutput" - name: unlinkRisk + title: Unlink Risk description: Unlink a risk from a resource (document, measure, or obligation). The resource type is determined from the resource_id GID. hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/UnlinkRiskInput" outputSchema: $ref: "#/components/schemas/UnlinkRiskOutput" - name: listTasks + title: List Tasks description: List all tasks for the organization or measure hints: readonly: true @@ -13482,6 +13597,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListTasksOutput" - name: getTask + title: Get Task description: Get a task by ID hints: readonly: true @@ -13491,6 +13607,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetTaskOutput" - name: addTask + title: Add Task description: Add a new task to the organization hints: readonly: false @@ -13499,6 +13616,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddTaskOutput" - name: updateTask + title: Update Task description: Update an existing task hints: readonly: false @@ -13507,6 +13625,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateTaskOutput" - name: assignTask + title: Assign Task description: Assign a task to a person hints: readonly: false @@ -13515,6 +13634,7 @@ tools: outputSchema: $ref: "#/components/schemas/AssignTaskOutput" - name: unassignTask + title: Unassign Task description: Unassign a task from a person hints: readonly: false @@ -13523,6 +13643,7 @@ tools: outputSchema: $ref: "#/components/schemas/UnassignTaskOutput" - name: deleteTask + title: Delete Task description: Delete a task hints: readonly: false @@ -13532,6 +13653,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteTaskOutput" - name: listDocuments + title: List Documents description: List documents for the organization. By default only ACTIVE documents are returned; pass status filter to include ARCHIVED. hints: readonly: true @@ -13541,6 +13663,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListDocumentsOutput" - name: getDocument + title: Get Document description: Get a document by ID hints: readonly: true @@ -13550,6 +13673,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetDocumentOutput" - name: addDocument + title: Add Document description: Add a new document to the organization hints: readonly: false @@ -13558,6 +13682,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddDocumentOutput" - name: updateDocument + title: Update Document description: Update an existing document hints: readonly: false @@ -13566,6 +13691,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateDocumentOutput" - name: deleteDocumentDraft + title: Delete Document Draft description: Delete the latest draft version of a document, reverting to the last published version. Cannot delete the initial v0.1 draft. hints: readonly: false @@ -13575,6 +13701,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteDocumentDraftOutput" - name: archiveDocument + title: Archive Document description: Archive a document to prevent further modifications hints: readonly: false @@ -13583,6 +13710,7 @@ tools: outputSchema: $ref: "#/components/schemas/ArchiveDocumentOutput" - name: unarchiveDocument + title: Unarchive Document description: Unarchive a document to allow modifications again hints: readonly: false @@ -13591,6 +13719,7 @@ tools: outputSchema: $ref: "#/components/schemas/UnarchiveDocumentOutput" - name: listDocumentVersions + title: List Document Versions description: List all versions for a document hints: readonly: true @@ -13600,6 +13729,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListDocumentVersionsOutput" - name: getDocumentVersion + title: Get Document Version description: Get a document version by ID hints: readonly: true @@ -13609,6 +13739,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetDocumentVersionOutput" - name: publishDocument + title: Publish Document description: Publish the latest draft of a document. Set minor=true to publish a minor version (no approval flow). When minor=false, providing approver_ids triggers an approval request as a new major version; otherwise the draft is published immediately as a new major version. hints: readonly: false @@ -13617,6 +13748,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishDocumentOutput" - name: deleteDocument + title: Delete Document description: Delete a document hints: readonly: false @@ -13626,6 +13758,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteDocumentOutput" - name: listDocumentVersionSignatures + title: List Document Version Signatures description: List all signatures for a document version hints: readonly: true @@ -13635,6 +13768,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListDocumentVersionSignaturesOutput" - name: getDocumentVersionSignature + title: Get Document Version Signature description: Get a document version signature by ID hints: readonly: true @@ -13644,6 +13778,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetDocumentVersionSignatureOutput" - name: requestDocumentVersionSignature + title: Request Document Version Signature description: Request a signature for a document version hints: readonly: false @@ -13652,6 +13787,7 @@ tools: outputSchema: $ref: "#/components/schemas/RequestDocumentVersionSignatureOutput" - name: cancelSignatureRequest + title: Cancel Signature Request description: Cancel a document version signature request hints: readonly: false @@ -13661,6 +13797,7 @@ tools: outputSchema: $ref: "#/components/schemas/CancelSignatureRequestOutput" - name: voidDocumentVersionApproval + title: Void Document Version Approval description: Void a pending document version approval request hints: readonly: false @@ -13670,6 +13807,7 @@ tools: outputSchema: $ref: "#/components/schemas/VoidDocumentVersionApprovalOutput" - name: listStatementsOfApplicability + title: List Statements of Applicability description: List all statements of applicability for the organization hints: readonly: true @@ -13679,6 +13817,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListStatementsOfApplicabilityOutput" - name: getStatementOfApplicability + title: Get Statement of Applicability description: Get a statement of applicability by ID hints: readonly: true @@ -13688,6 +13827,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetStatementOfApplicabilityOutput" - name: addStatementOfApplicability + title: Add Statement of Applicability description: Add a new statement of applicability to the organization hints: readonly: false @@ -13696,6 +13836,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddStatementOfApplicabilityOutput" - name: updateStatementOfApplicability + title: Update Statement of Applicability description: Update an existing statement of applicability hints: readonly: false @@ -13704,6 +13845,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateStatementOfApplicabilityOutput" - name: deleteStatementOfApplicability + title: Delete Statement of Applicability description: Delete a statement of applicability hints: readonly: false @@ -13713,6 +13855,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteStatementOfApplicabilityOutput" - name: publishDataList + title: Publish Data List description: Publish the data list for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13721,6 +13864,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishDataListOutput" - name: publishAssetList + title: Publish Asset List description: Publish the asset list for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13729,6 +13873,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishAssetListOutput" - name: publishFindingList + title: Publish Finding List description: Publish the finding register for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13737,6 +13882,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishFindingListOutput" - name: publishObligationList + title: Publish Obligation List description: Publish the obligation register for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13745,6 +13891,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishObligationListOutput" - name: publishProcessingActivityList + title: Publish Processing Activity List description: Publish the processing activity register for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13753,6 +13900,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishProcessingActivityListOutput" - name: publishDataProtectionImpactAssessmentList + title: Publish Data Protection Impact Assessment List description: Publish the Data Protection Impact Assessment register for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13761,6 +13909,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishDataProtectionImpactAssessmentListOutput" - name: publishTransferImpactAssessmentList + title: Publish Transfer Impact Assessment List description: Publish the Transfer Impact Assessment register for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13769,6 +13918,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishTransferImpactAssessmentListOutput" - name: publishThirdPartyList + title: Publish Third Party List description: Publish the thirdParty register for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13777,6 +13927,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishThirdPartyListOutput" - name: publishRiskList + title: Publish Risk List description: Publish the risk register for an organization as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13785,6 +13936,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishRiskListOutput" - name: publishStatementOfApplicability + title: Publish Statement of Applicability description: Publish a statement of applicability as a document. If a document already exists, a new version is created. hints: readonly: false @@ -13793,6 +13945,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishStatementOfApplicabilityOutput" - name: listApplicabilityStatements + title: List Applicability Statements description: List all applicability statements for a statement of applicability hints: readonly: true @@ -13802,6 +13955,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListApplicabilityStatementsOutput" - name: getApplicabilityStatement + title: Get Applicability Statement description: Get an applicability statement by ID hints: readonly: true @@ -13811,6 +13965,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetApplicabilityStatementOutput" - name: addApplicabilityStatement + title: Add Applicability Statement description: Add a control to a statement of applicability with an applicability decision hints: readonly: false @@ -13819,6 +13974,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddApplicabilityStatementOutput" - name: updateApplicabilityStatement + title: Update Applicability Statement description: Update the applicability and justification of an applicability statement hints: readonly: false @@ -13827,6 +13983,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateApplicabilityStatementOutput" - name: deleteApplicabilityStatement + title: Delete Applicability Statement description: Delete an applicability statement from a statement of applicability hints: readonly: false @@ -13836,6 +13993,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteApplicabilityStatementOutput" - name: listAccessReviewCampaigns + title: List Access Review Campaigns description: List access review campaigns for an organization hints: readonly: true @@ -13845,6 +14003,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListAccessReviewCampaignsOutput" - name: listAccessEntries + title: List Access Entries description: List access entries for a campaign with optional filters (decision, flag, incremental_tag, is_admin, active, auth_method, account_type) hints: readonly: true @@ -13854,6 +14013,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListAccessEntriesOutput" - name: getAccessReviewStatistics + title: Get Access Review Statistics description: Get statistics for an access review campaign including counts by decision, flag, and incremental tag hints: readonly: true @@ -13863,6 +14023,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetAccessReviewStatisticsOutput" - name: recordAccessReviewEntryDecision + title: Record Access Review Entry Decision description: Record a decision on an access entry (APPROVED, REVOKE, DEFER, or ESCALATE). Non-APPROVED decisions require a decision_note. hints: readonly: false @@ -13871,6 +14032,7 @@ tools: outputSchema: $ref: "#/components/schemas/RecordAccessReviewEntryDecisionMCPOutput" - name: recordAccessReviewEntryDecisions + title: Record Access Review Entry Decisions description: Record decisions on multiple access entries in a single batch. Non-APPROVED decisions require a decision_note. hints: readonly: false @@ -13879,6 +14041,7 @@ tools: outputSchema: $ref: "#/components/schemas/RecordAccessReviewEntryDecisionsMCPOutput" - name: flagAccessReviewEntry + title: Flag Access Review Entry description: Flag an access entry with one or more flags during review (ORPHANED, INACTIVE, EXCESSIVE, ROLE_MISMATCH, NEW, etc.). Optionally provide reasons. hints: readonly: false @@ -13887,6 +14050,7 @@ tools: outputSchema: $ref: "#/components/schemas/FlagAccessReviewEntryMCPOutput" - name: closeAccessReviewCampaign + title: Close Access Review Campaign description: Close an access review campaign. All entries must have been decided (no PENDING entries). hints: readonly: false @@ -13895,6 +14059,7 @@ tools: outputSchema: $ref: "#/components/schemas/CloseAccessReviewCampaignMCPOutput" - name: listAccessReviewSources + title: List Access Review Sources description: List access sources for an organization hints: readonly: true @@ -13904,6 +14069,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListAccessReviewSourcesOutput" - name: createAccessReviewSource + title: Create Access Review Source description: Create a new access source for an organization hints: readonly: false @@ -13912,6 +14078,7 @@ tools: outputSchema: $ref: "#/components/schemas/CreateAccessReviewSourceMCPOutput" - name: updateAccessReviewSource + title: Update Access Review Source description: Update an existing access source hints: readonly: false @@ -13920,6 +14087,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateAccessReviewSourceMCPOutput" - name: deleteAccessReviewSource + title: Delete Access Review Source description: Delete an access source hints: readonly: false @@ -13929,6 +14097,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteAccessReviewSourceMCPOutput" - name: createAccessReviewCampaign + title: Create Access Review Campaign description: Create a new access review campaign for an organization hints: readonly: false @@ -13937,6 +14106,7 @@ tools: outputSchema: $ref: "#/components/schemas/CreateAccessReviewCampaignMCPOutput" - name: updateAccessReviewCampaign + title: Update Access Review Campaign description: Update an existing access review campaign hints: readonly: false @@ -13945,6 +14115,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateAccessReviewCampaignMCPOutput" - name: deleteAccessReviewCampaign + title: Delete Access Review Campaign description: Delete an access review campaign hints: readonly: false @@ -13954,6 +14125,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteAccessReviewCampaignMCPOutput" - name: startAccessReviewCampaign + title: Start Access Review Campaign description: Start an access review campaign. Triggers data fetching from all configured scope sources. hints: readonly: false @@ -13962,14 +14134,17 @@ tools: outputSchema: $ref: "#/components/schemas/StartAccessReviewCampaignMCPOutput" - name: cancelAccessReviewCampaign + title: Cancel Access Review Campaign description: Cancel an in-progress access review campaign hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/CancelAccessReviewCampaignMCPInput" outputSchema: $ref: "#/components/schemas/CancelAccessReviewCampaignMCPOutput" - name: addAccessReviewCampaignSource + title: Add Access Review Campaign Source description: Add an access source to an access review campaign's scope hints: readonly: false @@ -13978,14 +14153,17 @@ tools: outputSchema: $ref: "#/components/schemas/AddAccessReviewCampaignSourceMCPOutput" - name: removeAccessReviewCampaignSource + title: Remove Access Review Campaign Source description: Remove an access source from an access review campaign's scope hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/RemoveAccessReviewCampaignSourceMCPInput" outputSchema: $ref: "#/components/schemas/RemoveAccessReviewCampaignSourceMCPOutput" - name: getOrganizationContext + title: Get Organization Context description: Get the organization context containing structured sections about the company hints: readonly: true @@ -13995,6 +14173,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetOrganizationContextOutput" - name: updateOrganizationContext + title: Update Organization Context description: Update the organization context sections (product, architecture, team, processes, customers) hints: readonly: false @@ -14003,6 +14182,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateOrganizationContextOutput" - name: getAuditLogEntry + title: Get Audit Log Entry description: Get an audit log entry by ID hints: readonly: true @@ -14012,6 +14192,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetAuditLogEntryOutput" - name: listAuditLogEntries + title: List Audit Log Entries description: List audit log entries for the organization. Audit log entries record write actions (create, update, delete) performed by users and API keys. hints: readonly: true @@ -14021,6 +14202,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListAuditLogEntriesOutput" - name: requestAuditLogExport + title: Request Audit Log Export description: Request an export of audit log entries for the organization within a time range. The export will be emailed as a JSONL download link. hints: readonly: false @@ -14030,6 +14212,7 @@ tools: outputSchema: $ref: "#/components/schemas/RequestAuditLogExportOutput" - name: requestSCIMEventExport + title: Request SCIM Event Export description: Request an export of SCIM events for the organization within a time range. The export will be emailed as a JSONL download link. hints: readonly: false @@ -14039,6 +14222,7 @@ tools: outputSchema: $ref: "#/components/schemas/RequestSCIMEventExportOutput" - name: listWebhookSubscriptions + title: List Webhook Subscriptions description: List all webhook subscriptions for the organization hints: readonly: true @@ -14048,6 +14232,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListWebhookSubscriptionsOutput" - name: getWebhookSubscription + title: Get Webhook Subscription description: Get a webhook subscription by ID hints: readonly: true @@ -14057,6 +14242,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetWebhookSubscriptionOutput" - name: createWebhookSubscription + title: Create Webhook Subscription description: Create a new webhook subscription for the organization. The endpoint URL must use HTTPS. Selected events determine which events trigger webhook deliveries. hints: readonly: false @@ -14065,6 +14251,7 @@ tools: outputSchema: $ref: "#/components/schemas/CreateWebhookSubscriptionOutput" - name: updateWebhookSubscription + title: Update Webhook Subscription description: Update a webhook subscription's endpoint URL or selected events hints: readonly: false @@ -14073,6 +14260,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateWebhookSubscriptionOutput" - name: deleteWebhookSubscription + title: Delete Webhook Subscription description: Delete a webhook subscription hints: readonly: false @@ -14082,6 +14270,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteWebhookSubscriptionOutput" - name: listWebhookEvents + title: List Webhook Events description: List webhook delivery events for a subscription. Shows delivery status (PENDING, SUCCEEDED, FAILED) and response details. hints: readonly: true @@ -14091,6 +14280,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListWebhookEventsOutput" - name: listDocumentVersionApprovalQuorums + title: List Document Version Approval Quorums description: List approval quorums for a document version hints: readonly: true @@ -14100,6 +14290,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListDocumentVersionApprovalQuorumsOutput" - name: getDocumentVersionApprovalQuorum + title: Get Document Version Approval Quorum description: Get a document version approval quorum by ID hints: readonly: true @@ -14109,6 +14300,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetDocumentVersionApprovalQuorumOutput" - name: listDocumentVersionApprovalDecisions + title: List Document Version Approval Decisions description: List approval decisions for an approval quorum hints: readonly: true @@ -14118,6 +14310,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListDocumentVersionApprovalDecisionsOutput" - name: getDocumentVersionApprovalDecision + title: Get Document Version Approval Decision description: Get a document version approval decision by ID hints: readonly: true @@ -14127,6 +14320,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetDocumentVersionApprovalDecisionOutput" - name: listRightsRequests + title: List Rights Requests description: List all rights requests for the organization hints: readonly: true @@ -14136,6 +14330,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRightsRequestsOutput" - name: getRightsRequest + title: Get Rights Request description: Get a rights request by ID hints: readonly: true @@ -14145,6 +14340,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRightsRequestOutput" - name: addRightsRequest + title: Add Rights Request description: Add a new rights request to the organization hints: readonly: false @@ -14153,6 +14349,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRightsRequestOutput" - name: updateRightsRequest + title: Update Rights Request description: Update an existing rights request hints: readonly: false @@ -14161,6 +14358,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRightsRequestOutput" - name: deleteRightsRequest + title: Delete Rights Request description: Delete a rights request hints: readonly: false @@ -14170,6 +14368,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRightsRequestOutput" - name: getCompliancePortal + title: Get Compliance Portal description: Get the compliance portal for an organization hints: readonly: true @@ -14179,6 +14378,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetCompliancePortalOutput" - name: updateCompliancePortal + title: Update Compliance Portal description: Update a compliance portal's settings hints: readonly: false @@ -14187,6 +14387,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateCompliancePortalOutput" - name: listCompliancePortalReferences + title: List Compliance Portal References description: List all references for a compliance portal hints: readonly: true @@ -14196,6 +14397,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCompliancePortalReferencesOutput" - name: addCompliancePortalReference + title: Add Compliance Portal Reference description: Add a new reference to a compliance portal hints: readonly: false @@ -14204,6 +14406,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddCompliancePortalReferenceOutput" - name: updateCompliancePortalReference + title: Update Compliance Portal Reference description: Update an existing compliance portal reference hints: readonly: false @@ -14212,6 +14415,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateCompliancePortalReferenceOutput" - name: deleteCompliancePortalReference + title: Delete Compliance Portal Reference description: Delete a compliance portal reference hints: readonly: false @@ -14221,6 +14425,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteCompliancePortalReferenceOutput" - name: listCommitmentGroups + title: List Commitment Groups description: List all commitment groups for a trust center hints: readonly: true @@ -14230,6 +14435,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCommitmentGroupsOutput" - name: addCommitmentGroup + title: Add Commitment Group description: Add a new commitment group to a trust center hints: readonly: false @@ -14238,6 +14444,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddCommitmentGroupOutput" - name: updateCommitmentGroup + title: Update Commitment Group description: Update an existing commitment group hints: readonly: false @@ -14246,6 +14453,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateCommitmentGroupOutput" - name: deleteCommitmentGroup + title: Delete Commitment Group description: Delete a commitment group hints: readonly: false @@ -14255,6 +14463,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteCommitmentGroupOutput" - name: listCommitments + title: List Commitments description: List all commitments in a commitment group hints: readonly: true @@ -14264,6 +14473,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCommitmentsOutput" - name: addCommitment + title: Add Commitment description: Add a new commitment to a commitment group hints: readonly: false @@ -14272,6 +14482,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddCommitmentOutput" - name: updateCommitment + title: Update Commitment description: Update an existing commitment hints: readonly: false @@ -14280,6 +14491,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateCommitmentOutput" - name: deleteCommitment + title: Delete Commitment description: Delete a commitment hints: readonly: false @@ -14289,6 +14501,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteCommitmentOutput" - name: setResourceAlias + title: Set Resource Alias description: Set a resource alias for a resource hints: readonly: false @@ -14297,6 +14510,7 @@ tools: outputSchema: $ref: "#/components/schemas/SetResourceAliasOutput" - name: removeResourceAlias + title: Remove Resource Alias description: Remove a resource alias from a resource hints: readonly: false @@ -14306,6 +14520,7 @@ tools: outputSchema: $ref: "#/components/schemas/RemoveResourceAliasOutput" - name: listCompliancePortalFiles + title: List Compliance Portal Files description: List all files for the compliance portal hints: readonly: true @@ -14315,6 +14530,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCompliancePortalFilesOutput" - name: deleteCompliancePortalFile + title: Delete Compliance Portal File description: Delete a compliance portal file hints: readonly: false @@ -14324,6 +14540,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteCompliancePortalFileOutput" - name: listComplianceCustomLinks + title: List Compliance Custom Links description: List all compliance custom links for a compliance portal hints: readonly: true @@ -14333,6 +14550,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListComplianceCustomLinksOutput" - name: addComplianceCustomLink + title: Add Compliance Custom Link description: Add a new compliance custom link to a compliance portal hints: readonly: false @@ -14341,6 +14559,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddComplianceCustomLinkOutput" - name: updateComplianceCustomLink + title: Update Compliance Custom Link description: Update an existing compliance custom link hints: readonly: false @@ -14349,6 +14568,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateComplianceCustomLinkOutput" - name: deleteComplianceCustomLink + title: Delete Compliance Custom Link description: Delete a compliance custom link hints: readonly: false @@ -14358,6 +14578,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteComplianceCustomLinkOutput" - name: createCustomDomain + title: Create Custom Domain description: Create a custom domain for a compliance page hints: readonly: false @@ -14366,6 +14587,7 @@ tools: outputSchema: $ref: "#/components/schemas/CreateCustomDomainOutput" - name: deleteCustomDomain + title: Delete Custom Domain description: Delete the custom domain of a compliance page hints: readonly: false @@ -14375,6 +14597,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteCustomDomainOutput" - name: listCookieBanners + title: List Cookie Banners description: List all cookie banners for the organization hints: readonly: true @@ -14384,6 +14607,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCookieBannersOutput" - name: getCookieBanner + title: Get Cookie Banner description: Get a cookie banner by ID hints: readonly: true @@ -14393,6 +14617,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetCookieBannerOutput" - name: addCookieBanner + title: Add Cookie Banner description: Create a new cookie banner for an organization hints: readonly: false @@ -14401,6 +14626,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddCookieBannerOutput" - name: updateCookieBanner + title: Update Cookie Banner description: Update an existing cookie banner hints: readonly: false @@ -14409,6 +14635,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateCookieBannerOutput" - name: deleteCookieBanner + title: Delete Cookie Banner description: Delete a cookie banner hints: readonly: false @@ -14418,6 +14645,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteCookieBannerOutput" - name: activateCookieBanner + title: Activate Cookie Banner description: Activate a cookie banner hints: readonly: false @@ -14426,6 +14654,7 @@ tools: outputSchema: $ref: "#/components/schemas/ActivateCookieBannerOutput" - name: deactivateCookieBanner + title: Deactivate Cookie Banner description: Deactivate a cookie banner hints: readonly: false @@ -14434,6 +14663,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeactivateCookieBannerOutput" - name: listCookieCategories + title: List Cookie Categories description: List all cookie categories for a banner hints: readonly: true @@ -14443,6 +14673,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCookieCategoriesOutput" - name: getCookieCategory + title: Get Cookie Category description: Get a cookie category by ID hints: readonly: true @@ -14452,6 +14683,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetCookieCategoryOutput" - name: addCookieCategory + title: Add Cookie Category description: Create a new cookie category for a banner hints: readonly: false @@ -14460,6 +14692,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddCookieCategoryOutput" - name: updateCookieCategory + title: Update Cookie Category description: Update an existing cookie category hints: readonly: false @@ -14468,6 +14701,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateCookieCategoryOutput" - name: deleteCookieCategory + title: Delete Cookie Category description: Delete a cookie category hints: readonly: false @@ -14477,6 +14711,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteCookieCategoryOutput" - name: reorderCookieCategory + title: Reorder Cookie Category description: Change the display order rank of a cookie category hints: readonly: false @@ -14485,6 +14720,7 @@ tools: outputSchema: $ref: "#/components/schemas/ReorderCookieCategoryOutput" - name: listTrackerPatterns + title: List Tracker Patterns description: List all tracker patterns for a category hints: readonly: true @@ -14494,6 +14730,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListTrackerPatternsOutput" - name: getTrackerPattern + title: Get Tracker Pattern description: Get a tracker pattern by ID hints: readonly: true @@ -14503,6 +14740,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetTrackerPatternOutput" - name: addTrackerPattern + title: Add Tracker Pattern description: Create a new tracker pattern for a category hints: readonly: false @@ -14511,6 +14749,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddTrackerPatternOutput" - name: updateTrackerPattern + title: Update Tracker Pattern description: Update an existing tracker pattern hints: readonly: false @@ -14519,6 +14758,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateTrackerPatternOutput" - name: deleteTrackerPattern + title: Delete Tracker Pattern description: Delete a tracker pattern hints: readonly: false @@ -14528,6 +14768,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteTrackerPatternOutput" - name: moveTrackerPatternToCategory + title: Move Tracker Pattern to Category description: Move a tracker pattern to a different category hints: readonly: false @@ -14536,6 +14777,7 @@ tools: outputSchema: $ref: "#/components/schemas/MoveTrackerPatternToCategoryOutput" - name: listTrackerResources + title: List Tracker Resources description: List all tracker resources for a category hints: readonly: true @@ -14545,6 +14787,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListTrackerResourcesOutput" - name: getTrackerResource + title: Get Tracker Resource description: Get a tracker resource by ID hints: readonly: true @@ -14554,6 +14797,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetTrackerResourceOutput" - name: addTrackerResource + title: Add Tracker Resource description: Create a new tracker resource for a category hints: readonly: false @@ -14562,6 +14806,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddTrackerResourceOutput" - name: updateTrackerResource + title: Update Tracker Resource description: Update an existing tracker resource hints: readonly: false @@ -14570,6 +14815,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateTrackerResourceOutput" - name: deleteTrackerResource + title: Delete Tracker Resource description: Delete a tracker resource hints: readonly: false @@ -14579,6 +14825,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteTrackerResourceOutput" - name: moveTrackerResourceToCategory + title: Move Tracker Resource to Category description: Move a tracker resource to a different category hints: readonly: false @@ -14587,6 +14834,7 @@ tools: outputSchema: $ref: "#/components/schemas/MoveTrackerResourceToCategoryOutput" - name: publishCookieBannerVersion + title: Publish Cookie Banner Version description: Publish the current draft version of a cookie banner hints: readonly: false @@ -14595,6 +14843,7 @@ tools: outputSchema: $ref: "#/components/schemas/PublishCookieBannerVersionOutput" - name: regenerateCookieBannerTrackerPolicy + title: Regenerate Cookie Banner Tracker Policy description: Re-arm tracker policy generation for a cookie banner that already has a published version. Returns immediately; the policy document is regenerated in the background. hints: readonly: false @@ -14603,6 +14852,7 @@ tools: outputSchema: $ref: "#/components/schemas/RegenerateCookieBannerTrackerPolicyOutput" - name: listCookieBannerVersions + title: List Cookie Banner Versions description: List all versions for a cookie banner hints: readonly: true @@ -14612,6 +14862,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCookieBannerVersionsOutput" - name: upsertCookieBannerTranslation + title: Upsert Cookie Banner Translation description: Insert or update a cookie banner translation for a language hints: readonly: false @@ -14620,6 +14871,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpsertCookieBannerTranslationOutput" - name: listCookieConsentRecords + title: List Cookie Consent Records description: List consent records for a cookie banner hints: readonly: true @@ -14629,6 +14881,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListCookieConsentRecordsOutput" - name: getCookieConsentRecord + title: Get Cookie Consent Record description: Get a consent record by ID hints: readonly: true @@ -14638,6 +14891,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetCookieConsentRecordOutput" - name: getSCIMConfiguration + title: Get SCIM Configuration description: Get the SCIM configuration for an organization hints: readonly: true @@ -14647,6 +14901,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetSCIMConfigurationOutput" - name: createSCIMConfiguration + title: Create SCIM Configuration description: Create a SCIM configuration for an organization. Optionally provide a connector ID to also create a SCIM bridge. hints: readonly: false @@ -14655,6 +14910,7 @@ tools: outputSchema: $ref: "#/components/schemas/CreateSCIMConfigurationOutput" - name: deleteSCIMConfiguration + title: Delete SCIM Configuration description: Delete a SCIM configuration and its associated bridge hints: readonly: false @@ -14664,6 +14920,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteSCIMConfigurationOutput" - name: regenerateSCIMToken + title: Regenerate SCIM Token description: Regenerate the bearer token for a SCIM configuration hints: readonly: false @@ -14672,6 +14929,7 @@ tools: outputSchema: $ref: "#/components/schemas/RegenerateSCIMTokenOutput" - name: getSCIMBridge + title: Get SCIM Bridge description: Get a SCIM bridge by ID hints: readonly: true @@ -14681,6 +14939,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetSCIMBridgeOutput" - name: updateSCIMBridge + title: Update SCIM Bridge description: Update a SCIM bridge's excluded user names hints: readonly: false @@ -14689,6 +14948,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateSCIMBridgeOutput" - name: listSCIMEvents + title: List SCIM Events description: List SCIM events for a configuration hints: readonly: true @@ -14698,6 +14958,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListSCIMEventsOutput" - name: listRiskAssessments + title: List Risk Assessments description: List all risk assessments for an organization hints: readonly: true @@ -14707,6 +14968,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskAssessmentsOutput" - name: getRiskAssessment + title: Get Risk Assessment description: Get a risk assessment by ID hints: readonly: true @@ -14716,6 +14978,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentOutput" - name: addRiskAssessment + title: Add Risk Assessment description: Create a new risk assessment hints: readonly: false @@ -14724,6 +14987,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskAssessmentOutput" - name: updateRiskAssessment + title: Update Risk Assessment description: Update an existing risk assessment hints: readonly: false @@ -14732,6 +14996,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskAssessmentOutput" - name: deleteRiskAssessment + title: Delete Risk Assessment description: Delete a risk assessment hints: readonly: false @@ -14741,6 +15006,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentOutput" - name: listRiskAssessmentScopes + title: List Risk Assessment Scopes description: List all scopes for a risk assessment hints: readonly: true @@ -14750,6 +15016,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskAssessmentScopesOutput" - name: getRiskAssessmentScope + title: Get Risk Assessment Scope description: Get a risk assessment scope by ID hints: readonly: true @@ -14759,6 +15026,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentScopeOutput" - name: getRiskAssessmentScopeMermaidChart + title: Get Risk Assessment Scope Mermaid Chart description: Get the Mermaid chart for a risk assessment scope hints: readonly: true @@ -14768,6 +15036,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentScopeMermaidChartOutput" - name: addRiskAssessmentScope + title: Add Risk Assessment Scope description: Create a new risk assessment scope hints: readonly: false @@ -14776,6 +15045,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskAssessmentScopeOutput" - name: updateRiskAssessmentScope + title: Update Risk Assessment Scope description: Update an existing risk assessment scope hints: readonly: false @@ -14784,6 +15054,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskAssessmentScopeOutput" - name: deleteRiskAssessmentScope + title: Delete Risk Assessment Scope description: Delete a risk assessment scope hints: readonly: false @@ -14793,6 +15064,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentScopeOutput" - name: listRiskAssessmentNodes + title: List Risk Assessment Nodes description: List all nodes for a risk assessment scope hints: readonly: true @@ -14802,6 +15074,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskAssessmentNodesOutput" - name: getRiskAssessmentNode + title: Get Risk Assessment Node description: Get a risk assessment node by ID hints: readonly: true @@ -14811,6 +15084,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentNodeOutput" - name: addRiskAssessmentNode + title: Add Risk Assessment Node description: Create a new risk assessment node hints: readonly: false @@ -14819,6 +15093,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskAssessmentNodeOutput" - name: updateRiskAssessmentNode + title: Update Risk Assessment Node description: Update an existing risk assessment node hints: readonly: false @@ -14827,6 +15102,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskAssessmentNodeOutput" - name: deleteRiskAssessmentNode + title: Delete Risk Assessment Node description: Delete a risk assessment node hints: readonly: false @@ -14836,6 +15112,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentNodeOutput" - name: listRiskAssessmentBoundaries + title: List Risk Assessment Boundaries description: List all boundaries for a risk assessment scope hints: readonly: true @@ -14845,6 +15122,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskAssessmentBoundariesOutput" - name: getRiskAssessmentBoundary + title: Get Risk Assessment Boundary description: Get a risk assessment boundary by ID hints: readonly: true @@ -14854,6 +15132,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentBoundaryOutput" - name: addRiskAssessmentBoundary + title: Add Risk Assessment Boundary description: Create a new risk assessment boundary hints: readonly: false @@ -14862,6 +15141,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskAssessmentBoundaryOutput" - name: updateRiskAssessmentBoundary + title: Update Risk Assessment Boundary description: Update an existing risk assessment boundary hints: readonly: false @@ -14870,6 +15150,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskAssessmentBoundaryOutput" - name: deleteRiskAssessmentBoundary + title: Delete Risk Assessment Boundary description: Delete a risk assessment boundary hints: readonly: false @@ -14879,6 +15160,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentBoundaryOutput" - name: listRiskAssessmentProcesses + title: List Risk Assessment Processes description: List all processes for a risk assessment scope hints: readonly: true @@ -14888,6 +15170,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskAssessmentProcessesOutput" - name: getRiskAssessmentProcess + title: Get Risk Assessment Process description: Get a risk assessment process by ID hints: readonly: true @@ -14897,6 +15180,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentProcessOutput" - name: addRiskAssessmentProcess + title: Add Risk Assessment Process description: Create a new risk assessment process hints: readonly: false @@ -14905,6 +15189,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskAssessmentProcessOutput" - name: updateRiskAssessmentProcess + title: Update Risk Assessment Process description: Update an existing risk assessment process hints: readonly: false @@ -14913,6 +15198,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskAssessmentProcessOutput" - name: deleteRiskAssessmentProcess + title: Delete Risk Assessment Process description: Delete a risk assessment process hints: readonly: false @@ -14922,6 +15208,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentProcessOutput" - name: listRiskAssessmentThreats + title: List Risk Assessment Threats description: List all threats for a risk assessment scope hints: readonly: true @@ -14931,6 +15218,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskAssessmentThreatsOutput" - name: getRiskAssessmentThreat + title: Get Risk Assessment Threat description: Get a risk assessment threat by ID hints: readonly: true @@ -14940,6 +15228,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentThreatOutput" - name: addRiskAssessmentThreat + title: Add Risk Assessment Threat description: Create a new risk assessment threat hints: readonly: false @@ -14948,6 +15237,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskAssessmentThreatOutput" - name: updateRiskAssessmentThreat + title: Update Risk Assessment Threat description: Update an existing risk assessment threat hints: readonly: false @@ -14956,6 +15246,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskAssessmentThreatOutput" - name: deleteRiskAssessmentThreat + title: Delete Risk Assessment Threat description: Delete a risk assessment threat hints: readonly: false @@ -14965,6 +15256,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentThreatOutput" - name: listRiskAssessmentScenarios + title: List Risk Assessment Scenarios description: List all scenarios for a risk assessment scope hints: readonly: true @@ -14974,6 +15266,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListRiskAssessmentScenariosOutput" - name: getRiskAssessmentScenario + title: Get Risk Assessment Scenario description: Get a risk assessment scenario by ID hints: readonly: true @@ -14983,6 +15276,7 @@ tools: outputSchema: $ref: "#/components/schemas/GetRiskAssessmentScenarioOutput" - name: addRiskAssessmentScenario + title: Add Risk Assessment Scenario description: Create a new risk assessment scenario hints: readonly: false @@ -14991,6 +15285,7 @@ tools: outputSchema: $ref: "#/components/schemas/AddRiskAssessmentScenarioOutput" - name: updateRiskAssessmentScenario + title: Update Risk Assessment Scenario description: Update an existing risk assessment scenario hints: readonly: false @@ -14999,6 +15294,7 @@ tools: outputSchema: $ref: "#/components/schemas/UpdateRiskAssessmentScenarioOutput" - name: deleteRiskAssessmentScenario + title: Delete Risk Assessment Scenario description: Delete a risk assessment scenario hints: readonly: false @@ -15008,6 +15304,7 @@ tools: outputSchema: $ref: "#/components/schemas/DeleteRiskAssessmentScenarioOutput" - name: linkRiskAssessmentScenarioThreat + title: Link Risk Assessment Scenario Threat description: Link a threat to a risk assessment scenario hints: readonly: false @@ -15016,14 +15313,17 @@ tools: outputSchema: $ref: "#/components/schemas/LinkRiskAssessmentScenarioThreatOutput" - name: unlinkRiskAssessmentScenarioThreat + title: Unlink Risk Assessment Scenario Threat description: Unlink a threat from a risk assessment scenario hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/UnlinkRiskAssessmentScenarioThreatInput" outputSchema: $ref: "#/components/schemas/UnlinkRiskAssessmentScenarioThreatOutput" - name: linkRiskAssessmentScenarioRisk + title: Link Risk Assessment Scenario Risk description: Link a risk to a risk assessment scenario hints: readonly: false @@ -15032,9 +15332,11 @@ tools: outputSchema: $ref: "#/components/schemas/LinkRiskAssessmentScenarioRiskOutput" - name: unlinkRiskAssessmentScenarioRisk + title: Unlink Risk Assessment Scenario Risk description: Unlink a risk from a risk assessment scenario hints: readonly: false + destructive: true inputSchema: $ref: "#/components/schemas/UnlinkRiskAssessmentScenarioRiskInput" outputSchema: diff --git a/third_party/mcpgen/LICENSE b/third_party/mcpgen/LICENSE new file mode 100644 index 000000000..4b33f7ee9 --- /dev/null +++ b/third_party/mcpgen/LICENSE @@ -0,0 +1,20 @@ +Copyright 2025 Probo Inc + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + ā€œSoftwareā€), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED ā€œAS ISā€, WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/third_party/mcpgen/README.md b/third_party/mcpgen/README.md new file mode 100644 index 000000000..3ad12c8f7 --- /dev/null +++ b/third_party/mcpgen/README.md @@ -0,0 +1,381 @@ +# mcpgen + +## Overview + +mcpgen is a code generator for Model Context Protocol (MCP) servers in Go, inspired by [gqlgen](https://github.com/99designs/gqlgen). + +mcpgen takes a schema-first approach to building MCP servers. Define your tools, resources, and prompts in a YAML configuration file with JSON Schema definitions, and mcpgen generates type-safe Go code including: + +- Type-safe Go structs from JSON Schemas +- MCP server boilerplate with the official [go-sdk](https://github.com/modelcontextprotocol/go-sdk) +- Handler function stubs ready for your business logic + +## Features + +- **Schema-First Development**: Define MCP primitives (tools, resources, prompts) in YAML with JSON Schema +- **Type-Safe Code Generation**: Generate Go structs from JSON Schema Draft 2020-12 +- **Custom Type Mapping**: Use your own Go types instead of generated ones (like gqlgen) +- **Omittable Fields**: Distinguish between "not set", "null", and "value" with `go.probo.inc/mcpgen/omittable` (like gqlgen's `@goField(omittable: true)`) +- **Official SDK Integration**: Uses the official `modelcontextprotocol/go-sdk` +- **Handler Preservation**: Regeneration preserves your handler implementations +- **gqlgen-Inspired**: Familiar workflow if you've used gqlgen + +## Installation + +```bash +go install go.probo.inc/mcpgen@latest +``` + +Or build from source: + +```bash +git clone https://github.com/probo-inc/mcpgen +cd mcpgen +go build -o mcpgen +``` + +## Quick Start + +### 1. Initialize a new project + +```bash +mcpgen init my-mcp-server +cd my-mcp-server +``` + +This creates: +``` +my-mcp-server/ +ā”œā”€ā”€ mcpgen.yaml # Configuration file +ā”œā”€ā”€ schemas/ # JSON Schema definitions +│ └── example_input.json +ā”œā”€ā”€ main.go # Entry point +└── README.md +``` + +### 2. Define your MCP primitives + +Edit `mcpgen.yaml`: + +```yaml +server: + name: my-mcp-server + version: 1.0.0 + +tools: + - name: calculate + description: Perform arithmetic operations + input_schema: schemas/calculate_input.json + +resources: + - uri: docs://readme + name: Project README + description: The project README file + mime_type: text/markdown + +prompts: + - name: greeting + description: A friendly greeting + arguments: + - name: name + description: Name of person to greet + required: false +``` + +### 3. Create JSON Schemas + +Define schemas in the `schemas/` directory: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"] + }, + "a": { + "type": "number", + "description": "First operand" + }, + "b": { + "type": "number", + "description": "Second operand" + } + }, + "required": ["operation", "a", "b"] +} +``` + +### 4. Generate code + +```bash +mcpgen generate +``` + +This generates: +- `generated/models.go` - Type-safe Go structs +- `generated/server.go` - MCP server setup +- `generated/resolver.go` - Handler stubs (first time only) + +### 5. Implement handlers + +Edit `generated/resolver.go`: + +```go +func (r *Resolver) Calculate(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, map[string]any, error) { + operation := args["operation"].(string) + a := args["a"].(float64) + b := args["b"].(float64) + + var result float64 + switch operation { + case "add": + result = a + b + case "subtract": + result = a - b + case "multiply": + result = a * b + case "divide": + if b == 0 { + return nil, nil, fmt.Errorf("division by zero") + } + result = a / b + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: fmt.Sprintf("Result: %f", result), + }, + }, + }, map[string]any{"result": result}, nil +} +``` + +### 6. Build and run + +```bash +go mod init my-mcp-server +go mod tidy +go build -o server +./server +``` + +## Configuration Reference + +### Server Configuration + +```yaml +server: + name: my-server # Required: Server name + version: 1.0.0 # Required: Server version +``` + +### Code Generation Options + +```yaml +exec: + filename: generated/server.go # Server code output + package: generated # Package name + +model: + filename: generated/models.go # Models output + package: generated # Package name + +resolver: + filename: generated/resolver.go # Resolver stubs output + type: Resolver # Resolver type name + package: generated # Package name + preserve_resolver: true # Don't overwrite on regeneration +``` + +### Tools + +```yaml +tools: + - name: tool_name # Required: Tool identifier + description: Tool description # Optional: Human-readable description + input_schema: schemas/input.json # Required: JSON Schema for input + output_schema: schemas/output.json # Optional: JSON Schema for output +``` + +### Resources + +Static resources: + +```yaml +resources: + - uri: docs://readme # Required: Resource URI + name: README # Required: Display name + description: Project README # Optional + mime_type: text/markdown # Optional +``` + +Resource templates (dynamic URIs): + +```yaml +resources: + - uri_template: users://{id}/profile # Required: URI template + name: User Profile # Required + description: User profile data # Optional + mime_type: application/json # Optional + uri_params: # Parameters from template + - name: id + type: string + description: User ID +``` + +### Prompts + +```yaml +prompts: + - name: prompt_name # Required: Prompt identifier + description: Description # Optional + arguments: # Optional: Prompt arguments + - name: arg_name + description: Arg description + required: true +``` + +## Commands + +### `mcpgen init [name]` + +Initialize a new MCP server project with example configuration. + +```bash +mcpgen init my-server +``` + +### `mcpgen generate` + +Generate code from `mcpgen.yaml` configuration. + +```bash +mcpgen generate + +# Specify custom config file +mcpgen generate --config custom-config.yaml +``` + +### `mcpgen version` + +Print mcpgen version. + +```bash +mcpgen version +``` + +## How It Works + +1. **Configuration Loading**: mcpgen reads your `mcpgen.yaml` file +2. **Schema Loading**: JSON Schemas are loaded and `$ref` references resolved +3. **Type Generation**: Go structs are generated from JSON Schemas +4. **Server Generation**: MCP server boilerplate is generated with tool/resource/prompt registration +5. **Resolver Generation**: Handler stubs are generated (only if they don't exist) + +## MCP Primitives + +### Tools + +Tools let LLMs interact with external systems. Each tool has: +- **Name**: Unique identifier (alphanumeric, underscore, dash, dot) +- **Description**: What the tool does +- **Input Schema**: JSON Schema defining parameters (required) +- **Output Schema**: JSON Schema for result validation (optional) + +### Resources + +Resources provide context to LLMs via URIs: +- **Static Resources**: Fixed URI (e.g., `docs://readme`) +- **Resource Templates**: Dynamic URIs (e.g., `users://{id}/profile`) + +### Prompts + +Prompts are reusable templates for LLM interactions with optional arguments. + +## Comparison with gqlgen + +| Feature | gqlgen | mcpgen | +|---------|--------|--------| +| **Schema Language** | GraphQL SDL | JSON Schema | +| **Protocol** | GraphQL | MCP (JSON-RPC 2.0) | +| **Core Primitives** | Queries, Mutations, Subscriptions | Tools, Resources, Prompts | +| **Generation** | Resolvers, models | Handlers, models | +| **Schema-first** | āœ… | āœ… | +| **Preserve implementations** | āœ… | āœ… | +| **Type safety** | āœ… | āœ… | + +## Custom Type Mapping + +You can use your own Go types instead of generated ones, similar to gqlgen's model binding. + +### Using Schema Annotations (Recommended) + +Add `go.probo.inc/mcpgen/type` annotations in your JSON Schema: + +```yaml +components: + schemas: + # Use time.Time for timestamps + Timestamp: + type: string + format: date-time + go.probo.inc/mcpgen/type: time.Time + + # Use UUID package + UUID: + type: string + format: uuid + go.probo.inc/mcpgen/type: github.com/google/uuid.UUID + + # Use your own domain models + User: + type: object + properties: + id: + type: string + name: + type: string + go.probo.inc/mcpgen/type: github.com/myorg/models.User +``` + +When you reference these schemas, mcpgen will: +- Skip generating types for them +- Use your custom types instead +- Automatically add necessary imports + +See [docs/custom-types.md](docs/custom-types.md) for full documentation. + +## Examples + +See the `examples/` directory for complete working examples. + +## Development + +### Building + +```bash +go build -o mcpgen +``` + +### Testing + +```bash +go test ./... +``` + +## Contributing + +Contributions welcome. Please submit a Pull Request. + +## License + +MIT License - see LICENSE file for details. + +## Acknowledgments + +- Inspired by [gqlgen](https://github.com/99designs/gqlgen) +- Uses the official [Model Context Protocol Go SDK](https://github.com/modelcontextprotocol/go-sdk) diff --git a/third_party/mcpgen/THIRD_PARTY.md b/third_party/mcpgen/THIRD_PARTY.md new file mode 100644 index 000000000..8ce1b179a --- /dev/null +++ b/third_party/mcpgen/THIRD_PARTY.md @@ -0,0 +1,12 @@ +# Vendored mcpgen + +This is a temporary fork of [getprobo/mcpgen](https://github.com/getprobo/mcpgen) +(module `go.probo.inc/mcpgen`) with tool annotation improvements: + +- `title` field on tools (emitted as `Tool.Title` and `ToolAnnotations.Title`) +- When `hints` are present, always emit annotations so write tools get + `readOnlyHint: false` and `destructiveHint: false`, distinguishing them from + deletes (`destructiveHint: true`) + +`go.mod` replaces `go.probo.inc/mcpgen` with this directory. Once the same +changes land upstream, drop the replace and delete this tree. diff --git a/third_party/mcpgen/go.mod b/third_party/mcpgen/go.mod new file mode 100644 index 000000000..7e3bf1bbe --- /dev/null +++ b/third_party/mcpgen/go.mod @@ -0,0 +1,21 @@ +module go.probo.inc/mcpgen + +go 1.25.3 + +require ( + github.com/google/jsonschema-go v0.3.0 + github.com/modelcontextprotocol/go-sdk v1.1.0 + github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 + golang.org/x/mod v0.30.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect +) diff --git a/third_party/mcpgen/go.sum b/third_party/mcpgen/go.sum new file mode 100644 index 000000000..076f147e6 --- /dev/null +++ b/third_party/mcpgen/go.sum @@ -0,0 +1,32 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= +github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/third_party/mcpgen/internal/codegen/generator.go b/third_party/mcpgen/internal/codegen/generator.go new file mode 100644 index 000000000..9920fee52 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/generator.go @@ -0,0 +1,1292 @@ +package codegen + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + + "go.probo.inc/mcpgen/internal/config" + "go.probo.inc/mcpgen/internal/schema" + "golang.org/x/mod/modfile" +) + +//go:embed templates/*.gotpl +var templates embed.FS + +type Generator struct { + config *config.Config + spec *config.MCPSpec + schemaLoader *schema.Loader + typeGen *TypeGenerator +} + +func New(cfg *config.Config, spec *config.MCPSpec) *Generator { + typeGen := NewTypeGenerator() + + // Sort schema names for deterministic output + schemaNames := make([]string, 0, len(cfg.Models.Models)) + for schemaName := range cfg.Models.Models { + schemaNames = append(schemaNames, schemaName) + } + sort.Strings(schemaNames) + + for _, schemaName := range schemaNames { + typeMapping := cfg.Models.Models[schemaName] + customMapping := parseTypeMapping(typeMapping.Model) + typeGen.AddCustomMapping(schemaName, customMapping) + } + + return &Generator{ + config: cfg, + spec: spec, + schemaLoader: schema.NewLoader("."), + typeGen: typeGen, + } +} + +func (g *Generator) Generate() error { + if err := g.loadSchemas(); err != nil { + return fmt.Errorf("failed to load schemas: %w", err) + } + + if err := g.generateModels(); err != nil { + return fmt.Errorf("failed to generate models: %w", err) + } + + if err := g.generateServer(); err != nil { + return fmt.Errorf("failed to generate server: %w", err) + } + + if err := g.generateResolverStruct(); err != nil { + return fmt.Errorf("failed to generate resolver struct: %w", err) + } + + if err := g.generateResolverImplementations(); err != nil { + return fmt.Errorf("failed to generate resolver implementations: %w", err) + } + + return nil +} + +func (g *Generator) loadSchemas() error { + // Sort schema names for deterministic output + schemaNames := make([]string, 0, len(g.spec.Components.Schemas)) + for name := range g.spec.Components.Schemas { + schemaNames = append(schemaNames, name) + } + sort.Strings(schemaNames) + + for _, name := range schemaNames { + schema := g.spec.Components.Schemas[name] + if config.IsSchemaRef(schema) { + s, err := g.schemaLoader.Load(schema.Ref) + if err != nil { + return fmt.Errorf("failed to load schema %s: %w", name, err) + } + if goType := extractGoTypeAnnotation(s); goType != "" { + customMapping := parseTypeMapping(goType) + g.typeGen.AddCustomMapping(name, customMapping) + } + g.typeGen.AddSchema(name, s) + } else { + if goType := extractGoTypeAnnotation(schema); goType != "" { + customMapping := parseTypeMapping(goType) + g.typeGen.AddCustomMapping(name, customMapping) + } + g.typeGen.AddSchema(name, schema) + } + } + + for _, tool := range g.spec.Tools { + if tool.InputSchema != nil { + typeName := toPascalCase(tool.Name) + "Input" + handlerName := toHandlerName(tool.Name) + schemaVarName := handlerName + "ToolInputSchema" + + var resolvedSchema *config.Schema + if config.IsSchemaRef(tool.InputSchema) { + if len(tool.InputSchema.Ref) > 0 && tool.InputSchema.Ref[0] == '#' { + resolved, err := g.spec.ResolveSchemaRef(tool.InputSchema.Ref) + if err != nil { + return fmt.Errorf("failed to resolve input schema ref for tool %s: %w", tool.Name, err) + } + resolvedSchema = resolved + g.typeGen.AddSchema(typeName, resolvedSchema) + } else { + s, err := g.schemaLoader.Load(tool.InputSchema.Ref) + if err != nil { + return fmt.Errorf("failed to load input schema for tool %s: %w", tool.Name, err) + } + resolvedSchema = s + g.typeGen.AddSchema(typeName, s) + } + } else { + resolvedSchema = tool.InputSchema + g.typeGen.AddSchema(typeName, tool.InputSchema) + } + + if resolvedSchema != nil { + fullyResolvedSchema, err := g.resolveAllRefs(resolvedSchema) + if err != nil { + return fmt.Errorf("failed to fully resolve schema for tool %s: %w", tool.Name, err) + } + schemaJSON, err := json.Marshal(fullyResolvedSchema) + if err == nil { + g.typeGen.AddSchemaVar(schemaVarName, string(schemaJSON)) + } + } + } + + // Process output schema if present + if tool.OutputSchema != nil { + typeName := toPascalCase(tool.Name) + "Output" + handlerName := toHandlerName(tool.Name) + schemaVarName := handlerName + "ToolOutputSchema" + + var resolvedSchema *config.Schema + if config.IsSchemaRef(tool.OutputSchema) { + if len(tool.OutputSchema.Ref) > 0 && tool.OutputSchema.Ref[0] == '#' { + resolved, err := g.spec.ResolveSchemaRef(tool.OutputSchema.Ref) + if err != nil { + return fmt.Errorf("failed to resolve output schema ref for tool %s: %w", tool.Name, err) + } + resolvedSchema = resolved + g.typeGen.AddSchema(typeName, resolvedSchema) + } else { + s, err := g.schemaLoader.Load(tool.OutputSchema.Ref) + if err != nil { + return fmt.Errorf("failed to load output schema for tool %s: %w", tool.Name, err) + } + resolvedSchema = s + g.typeGen.AddSchema(typeName, s) + } + } else { + resolvedSchema = tool.OutputSchema + g.typeGen.AddSchema(typeName, tool.OutputSchema) + } + + if resolvedSchema != nil { + fullyResolvedSchema, err := g.resolveAllRefs(resolvedSchema) + if err != nil { + return fmt.Errorf("failed to fully resolve schema for tool %s: %w", tool.Name, err) + } + schemaJSON, err := json.Marshal(fullyResolvedSchema) + if err == nil { + g.typeGen.AddSchemaVar(schemaVarName, string(schemaJSON)) + } + } + } + } + + for _, resource := range g.spec.Resources { + if resource.Schema != nil { + typeName := toPascalCase(resource.Name) + "Content" + if config.IsSchemaRef(resource.Schema) { + if len(resource.Schema.Ref) > 0 && resource.Schema.Ref[0] == '#' { + resolvedSchema, err := g.spec.ResolveSchemaRef(resource.Schema.Ref) + if err != nil { + return fmt.Errorf("failed to resolve schema ref for resource %s: %w", resource.Name, err) + } + g.typeGen.AddSchema(typeName, resolvedSchema) + continue + } + s, err := g.schemaLoader.Load(resource.Schema.Ref) + if err != nil { + return fmt.Errorf("failed to load schema for resource %s: %w", resource.Name, err) + } + g.typeGen.AddSchema(typeName, s) + } else { + g.typeGen.AddSchema(typeName, resource.Schema) + } + } + } + + // Generate typed argument structs for prompts + for _, prompt := range g.spec.Prompts { + if len(prompt.Arguments) > 0 { + typeName := toPascalCase(prompt.Name) + "Args" + + // Create a schema from the prompt arguments + argSchema := &config.Schema{ + Type: "object", + Properties: make(map[string]*config.Schema), + Required: []string{}, + } + + for _, arg := range prompt.Arguments { + argSchema.Properties[arg.Name] = &config.Schema{ + Type: "string", + Description: arg.Description, + } + if arg.Required { + argSchema.Required = append(argSchema.Required, arg.Name) + } + } + + g.typeGen.AddSchema(typeName, argSchema) + } + } + + return nil +} + +func (g *Generator) resolveAllRefs(s *config.Schema) (*config.Schema, error) { + if s == nil { + return nil, nil + } + + if config.IsSchemaRef(s) { + if len(s.Ref) > 0 && s.Ref[0] == '#' { + resolved, err := g.spec.ResolveSchemaRef(s.Ref) + if err != nil { + return nil, err + } + return g.resolveAllRefs(resolved) + } + return s, nil + } + + result := &config.Schema{ + Type: s.Type, + Types: s.Types, + Format: s.Format, + Description: s.Description, + Default: s.Default, + Enum: s.Enum, + Title: s.Title, + Required: s.Required, + ReadOnly: s.ReadOnly, + WriteOnly: s.WriteOnly, + Deprecated: s.Deprecated, + Minimum: s.Minimum, + Maximum: s.Maximum, + ExclusiveMinimum: s.ExclusiveMinimum, + ExclusiveMaximum: s.ExclusiveMaximum, + MinLength: s.MinLength, + MaxLength: s.MaxLength, + Pattern: s.Pattern, + MinItems: s.MinItems, + MaxItems: s.MaxItems, + UniqueItems: s.UniqueItems, + MinProperties: s.MinProperties, + MaxProperties: s.MaxProperties, + } + + if len(s.Properties) > 0 { + result.Properties = make(map[string]*config.Schema) + // Sort property names for deterministic output + propNames := make([]string, 0, len(s.Properties)) + for key := range s.Properties { + propNames = append(propNames, key) + } + sort.Strings(propNames) + for _, key := range propNames { + propSchema := s.Properties[key] + resolvedProp, err := g.resolveAllRefs(propSchema) + if err != nil { + return nil, fmt.Errorf("failed to resolve property %s: %w", key, err) + } + result.Properties[key] = resolvedProp + } + } + + if s.Items != nil { + resolvedItems, err := g.resolveAllRefs(s.Items) + if err != nil { + return nil, fmt.Errorf("failed to resolve items: %w", err) + } + result.Items = resolvedItems + } + + if len(s.AnyOf) > 0 { + result.AnyOf = make([]*config.Schema, len(s.AnyOf)) + for i, schema := range s.AnyOf { + resolvedSchema, err := g.resolveAllRefs(schema) + if err != nil { + return nil, fmt.Errorf("failed to resolve anyOf[%d]: %w", i, err) + } + result.AnyOf[i] = resolvedSchema + } + } + + if len(s.AllOf) > 0 { + result.AllOf = make([]*config.Schema, len(s.AllOf)) + for i, schema := range s.AllOf { + resolvedSchema, err := g.resolveAllRefs(schema) + if err != nil { + return nil, fmt.Errorf("failed to resolve allOf[%d]: %w", i, err) + } + result.AllOf[i] = resolvedSchema + } + } + + if len(s.OneOf) > 0 { + result.OneOf = make([]*config.Schema, len(s.OneOf)) + for i, schema := range s.OneOf { + resolvedSchema, err := g.resolveAllRefs(schema) + if err != nil { + return nil, fmt.Errorf("failed to resolve oneOf[%d]: %w", i, err) + } + result.OneOf[i] = resolvedSchema + } + } + + if s.Not != nil { + resolvedNot, err := g.resolveAllRefs(s.Not) + if err != nil { + return nil, fmt.Errorf("failed to resolve not: %w", err) + } + result.Not = resolvedNot + } + + if s.AdditionalProperties != nil { + resolvedAdditional, err := g.resolveAllRefs(s.AdditionalProperties) + if err != nil { + return nil, fmt.Errorf("failed to resolve additionalProperties: %w", err) + } + result.AdditionalProperties = resolvedAdditional + } + + if len(s.PatternProperties) > 0 { + result.PatternProperties = make(map[string]*config.Schema) + // Sort pattern names for deterministic output + patterns := make([]string, 0, len(s.PatternProperties)) + for pattern := range s.PatternProperties { + patterns = append(patterns, pattern) + } + sort.Strings(patterns) + for _, pattern := range patterns { + patternSchema := s.PatternProperties[pattern] + resolvedPattern, err := g.resolveAllRefs(patternSchema) + if err != nil { + return nil, fmt.Errorf("failed to resolve patternProperties[%s]: %w", pattern, err) + } + result.PatternProperties[pattern] = resolvedPattern + } + } + + return result, nil +} + +func toPascalCase(s string) string { + parts := strings.FieldsFunc(s, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' + }) + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + return strings.Join(parts, "") +} + +func (g *Generator) generateModels() error { + code, err := g.typeGen.Generate(g.config.Model.Package) + if err != nil { + return err + } + + modelsFile := "models.go" + if g.config.Model.Filename != "" { + modelsFile = g.config.Model.Filename + } + modelsPath := filepath.Join(g.config.Output, modelsFile) + + dir := filepath.Dir(modelsPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + if err := os.WriteFile(modelsPath, code, 0644); err != nil { + return fmt.Errorf("failed to write models file: %w", err) + } + + fmt.Printf("Generated models: %s\n", modelsPath) + return nil +} + +func (g *Generator) generateServer() error { + tmpl, err := template.ParseFS(templates, "templates/server.gotpl") + if err != nil { + return fmt.Errorf("failed to parse server template: %w", err) + } + + data := g.buildServerTemplateData() + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("failed to execute server template: %w", err) + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("failed to format server code: %w\n%s", err, buf.String()) + } + + serverFile := "server.go" + if g.config.Exec.Filename != "" { + serverFile = g.config.Exec.Filename + } + serverPath := filepath.Join(g.config.Output, serverFile) + + dir := filepath.Dir(serverPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + if err := os.WriteFile(serverPath, formatted, 0644); err != nil { + return fmt.Errorf("failed to write server file: %w", err) + } + + fmt.Printf("Generated server: %s\n", serverPath) + return nil +} + +// generateResolverStruct creates the main resolver.go file with the Resolver struct +// This file is only generated once and users can edit it freely +func (g *Generator) generateResolverStruct() error { + resolverFile := filepath.Join(g.config.Output, "resolver.go") + + // Only generate if file doesn't exist + if _, err := os.Stat(resolverFile); err == nil { + fmt.Printf("Resolver struct already exists, skipping: %s\n", resolverFile) + return nil + } + + tmpl, err := template.ParseFS(templates, "templates/resolver_struct.gotpl") + if err != nil { + return fmt.Errorf("failed to parse resolver_struct template: %w", err) + } + + data := map[string]interface{}{ + "Package": g.config.Resolver.Package, + "ResolverType": g.config.Resolver.Type, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("failed to execute resolver_struct template: %w", err) + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("failed to format resolver struct code: %w\n%s", err, buf.String()) + } + + if err := os.WriteFile(resolverFile, formatted, 0644); err != nil { + return fmt.Errorf("failed to write resolver struct file: %w", err) + } + + fmt.Printf("Generated resolver struct: %s\n", resolverFile) + return nil +} + +// generateResolverImplementations creates/updates schema.resolvers.go with tool/prompt/resource implementations +func (g *Generator) generateResolverImplementations() error { + resolverFile := filepath.Join(g.config.Output, "schema.resolvers.go") + + fileExists := false + if _, err := os.Stat(resolverFile); err == nil { + fileExists = true + } + + // If file doesn't exist, generate from template (initial generation) + if !fileExists { + return g.generateResolverFromTemplate(resolverFile) + } + + // File exists and preserve is enabled - do incremental update (gqlgen-style) + if g.config.Resolver.Preserve { + return g.updateResolverIncremental(resolverFile) + } + + // File exists but preserve is disabled - regenerate completely + return g.generateResolverFromTemplate(resolverFile) +} + +func (g *Generator) generateResolverFromTemplate(resolverFile string) error { + tmpl, err := template.ParseFS(templates, "templates/resolver.gotpl") + if err != nil { + return fmt.Errorf("failed to parse resolver template: %w", err) + } + + data := g.buildResolverTemplateData() + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("failed to execute resolver template: %w", err) + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("failed to format resolver code: %w\n%s", err, buf.String()) + } + + if err := os.WriteFile(resolverFile, formatted, 0644); err != nil { + return fmt.Errorf("failed to write resolver file: %w", err) + } + + fmt.Printf("Generated resolver implementations: %s\n", resolverFile) + return nil +} + +func (g *Generator) updateResolverIncremental(resolverFile string) error { + parser, err := NewResolverParser(resolverFile) + if err != nil { + return fmt.Errorf("failed to parse existing resolver: %w", err) + } + + existingHandlers, err := parser.ExtractHandlers(g.config.Resolver.Type) + if err != nil { + return fmt.Errorf("failed to extract handlers: %w", err) + } + + requiredHandlers := g.getRequiredHandlerNames() + + // Identify which handlers are new + newHandlers := []string{} + for _, required := range requiredHandlers { + if _, exists := existingHandlers[required]; !exists { + newHandlers = append(newHandlers, required) + } + } + + // Identify orphaned handlers (exist in file but not in spec, excluding already orphaned ones) + // First, get the list of handlers that were already in the orphaned section + previouslyOrphanedHandlers := extractOrphanedHandlerNames(resolverFile) + + // Identify which handlers are orphaned (not in required list and not already in orphaned section) + currentlyOrphanedHandlers := []string{} + for handlerName := range existingHandlers { + isRequired := false + for _, required := range requiredHandlers { + if required == handlerName { + isRequired = true + break + } + } + // Only mark as newly orphaned if not required and not already orphaned + if !isRequired && !contains(previouslyOrphanedHandlers, handlerName) { + currentlyOrphanedHandlers = append(currentlyOrphanedHandlers, handlerName) + } + } + + // Check if any previously orphaned handlers are now required (should be removed from orphaned) + orphanedHandlersRemoved := []string{} + for _, orphanedName := range previouslyOrphanedHandlers { + if contains(requiredHandlers, orphanedName) { + orphanedHandlersRemoved = append(orphanedHandlersRemoved, orphanedName) + } + } + + // Mark handlers as orphaned for formatting + IdentifyOrphanedHandlers(existingHandlers, requiredHandlers) + orphanedHandlers := FormatOrphanedHandlers(existingHandlers) + + // If nothing changed, skip update + if len(newHandlers) == 0 && len(currentlyOrphanedHandlers) == 0 && len(orphanedHandlersRemoved) == 0 { + fmt.Printf("Resolver is up to date, skipping: %s\n", resolverFile) + return nil + } + + // Generate code for new handlers only + newHandlersCode, err := g.generateNewHandlersCode(newHandlers) + if err != nil { + return fmt.Errorf("failed to generate new handlers: %w", err) + } + + // Read the existing file + content, err := os.ReadFile(resolverFile) + if err != nil { + return fmt.Errorf("failed to read resolver file: %w", err) + } + + // Remove any existing orphaned handlers section + contentStr := string(content) + if idx := strings.Index(contentStr, "\n// ==============================================================================\n// Orphaned Handlers\n"); idx != -1 { + contentStr = contentStr[:idx] + } + + // Build final content: existing code + new handlers + orphaned section + var buf bytes.Buffer + buf.WriteString(contentStr) + + if newHandlersCode != "" { + buf.WriteString("\n") + buf.WriteString(newHandlersCode) + } + + if orphanedHandlers != "" { + buf.WriteString(orphanedHandlers) + } + + // Format the final code + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("failed to format resolver code: %w\n%s", err, buf.String()) + } + + if err := os.WriteFile(resolverFile, formatted, 0644); err != nil { + return fmt.Errorf("failed to write resolver file: %w", err) + } + + // Build status message + var updates []string + if len(newHandlers) > 0 { + updates = append(updates, fmt.Sprintf("added %d new", len(newHandlers))) + } + if len(currentlyOrphanedHandlers) > 0 { + updates = append(updates, fmt.Sprintf("orphaned %d", len(currentlyOrphanedHandlers))) + } + if len(orphanedHandlersRemoved) > 0 { + updates = append(updates, fmt.Sprintf("restored %d from orphaned", len(orphanedHandlersRemoved))) + } + + fmt.Printf("Updated resolver: %s: %s\n", strings.Join(updates, ", "), resolverFile) + + return nil +} + +func countOrphanedHandlers(orphanedCode string) int { + return strings.Count(orphanedCode, "// Orphaned:") +} + +// extractOrphanedHandlerNames reads the orphaned section and returns list of handler names +func extractOrphanedHandlerNames(resolverFile string) []string { + content, err := os.ReadFile(resolverFile) + if err != nil { + return nil + } + + contentStr := string(content) + orphanedSectionStart := strings.Index(contentStr, "\n// ==============================================================================\n// Orphaned Handlers\n") + if orphanedSectionStart == -1 { + return nil + } + + orphanedSection := contentStr[orphanedSectionStart:] + var names []string + + // Find all "// Orphaned: " lines + lines := strings.Split(orphanedSection, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "// Orphaned: ") { + name := strings.TrimPrefix(line, "// Orphaned: ") + names = append(names, name) + } + } + + return names +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func (g *Generator) generateNewHandlersCode(handlerNames []string) (string, error) { + if len(handlerNames) == 0 { + return "", nil + } + + // Parse the resolver template to extract individual handler templates + tmpl, err := template.ParseFS(templates, "templates/resolver.gotpl") + if err != nil { + return "", fmt.Errorf("failed to parse resolver template: %w", err) + } + + // Build template data with only the new handlers + data := g.buildResolverTemplateData() + + // Filter to only include new handlers + handlerSet := make(map[string]bool) + for _, name := range handlerNames { + handlerSet[name] = true + } + + // Filter tools (HandlerName in data + "Tool" suffix should match required names) + if tools, ok := data["Tools"].([]map[string]interface{}); ok { + filteredTools := []map[string]interface{}{} + for _, tool := range tools { + if handlerName, ok := tool["HandlerName"].(string); ok { + if handlerSet[handlerName+"Tool"] { + filteredTools = append(filteredTools, tool) + } + } + } + data["Tools"] = filteredTools + } + + // Filter resources (HandlerName in data + "Resource" suffix should match required names) + if resources, ok := data["Resources"].([]map[string]interface{}); ok { + filteredResources := []map[string]interface{}{} + for _, resource := range resources { + if handlerName, ok := resource["HandlerName"].(string); ok { + if handlerSet[handlerName+"Resource"] { + filteredResources = append(filteredResources, resource) + } + } + } + data["Resources"] = filteredResources + data["HasResources"] = len(filteredResources) > 0 + } + + // Filter prompts (HandlerName in data + "Prompt" suffix should match required names) + if prompts, ok := data["Prompts"].([]map[string]interface{}); ok { + filteredPrompts := []map[string]interface{}{} + for _, prompt := range prompts { + if handlerName, ok := prompt["HandlerName"].(string); ok { + if handlerSet[handlerName+"Prompt"] { + filteredPrompts = append(filteredPrompts, prompt) + } + } + } + data["Prompts"] = filteredPrompts + data["HasPrompts"] = len(filteredPrompts) > 0 + } + + // Generate only handler methods (not the full file structure) + // NOTE: Must match the naming in resolver.gotpl template + handlersOnlyTmpl, err := template.New("handlers").Parse(` +{{- range .Tools }} + +{{- if .HasInputType }} +func (r *{{ $.ResolverType }}) {{ .HandlerName }}Tool(ctx context.Context, req *mcp.CallToolRequest, input *{{ .InputType }}) (*mcp.CallToolResult, {{ if .HasOutputType }}{{ .OutputType }}{{ else }}map[string]any{{ end }}, error) { + return nil, {{ if .HasOutputType }}{{ .OutputType }}{}{{ else }}nil{{ end }}, fmt.Errorf("{{ .Name }} not implemented") +} +{{- else }} +func (r *{{ $.ResolverType }}) {{ .HandlerName }}Tool(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, {{ if .HasOutputType }}{{ .OutputType }}{{ else }}map[string]any{{ end }}, error) { + return nil, {{ if .HasOutputType }}{{ .OutputType }}{}{{ else }}nil{{ end }}, fmt.Errorf("{{ .Name }} not implemented") +} +{{- end }} +{{- end }} + +{{- range .Resources }} + +func (r *{{ $.ResolverType }}) {{ .HandlerName }}Resource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return nil, fmt.Errorf("{{ .Name }} not implemented") +} +{{- end }} + +{{- range .Prompts }} + +{{- if .HasArgsType }} +func (r *{{ $.ResolverType }}) {{ .HandlerName }}Prompt(ctx context.Context, req *mcp.GetPromptRequest, args {{ .ArgsType }}) (*mcp.GetPromptResult, error) { + return nil, fmt.Errorf("{{ .Name }} not implemented") +} +{{- else }} +func (r *{{ $.ResolverType }}) {{ .HandlerName }}Prompt(ctx context.Context, req *mcp.GetPromptRequest, args map[string]string) (*mcp.GetPromptResult, error) { + return nil, fmt.Errorf("{{ .Name }} not implemented") +} +{{- end }} +{{- end }} +`) + if err != nil { + return "", fmt.Errorf("failed to create handlers template: %w", err) + } + + var buf bytes.Buffer + if err := handlersOnlyTmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute handlers template: %w", err) + } + + _ = tmpl // Keep using template for future enhancements + return buf.String(), nil +} + +func (g *Generator) getRequiredHandlerNames() []string { + var names []string + + for _, tool := range g.spec.Tools { + names = append(names, toHandlerName(tool.Name)+"Tool") + } + + for _, resource := range g.spec.Resources { + names = append(names, toHandlerName(resource.Name)+"Resource") + } + + for _, prompt := range g.spec.Prompts { + names = append(names, toHandlerName(prompt.Name)+"Prompt") + } + + return names +} + +func (g *Generator) buildServerTemplateData() map[string]interface{} { + // Compute type prefix if model package is different from exec package + modelPackage := g.config.Model.Package + execPackage := g.config.Exec.Package + typePrefix := "" + modelImportPath := "" + + // Server needs to import models if they're in a different package + if modelPackage != execPackage { + parts := strings.Split(modelPackage, "/") + typePrefix = parts[len(parts)-1] + "." + + // Compute the full import path for the model package + modelImportPath = g.computeModelImportPath() + } + + tools := make([]map[string]interface{}, 0, len(g.spec.Tools)) + hasTypedTools := false + for _, tool := range g.spec.Tools { + toolData := map[string]interface{}{ + "Name": tool.Name, + "Title": tool.Title, + "Description": tool.Description, + "HandlerName": toHandlerName(tool.Name), + } + + // Add hints if present + if tool.Hints != nil { + toolData["HasHints"] = true + toolData["Readonly"] = tool.Hints.Readonly + toolData["Destructive"] = tool.Hints.Destructive + toolData["Idempotent"] = tool.Hints.Idempotent + toolData["OpenWorld"] = tool.Hints.OpenWorld + } + + // Add input type information and schema code + if tool.InputSchema != nil { + inputTypeName := typePrefix + toPascalCase(tool.Name) + "Input" + toolData["InputType"] = inputTypeName + toolData["HasInputType"] = true + + // Add schema variable name with proper prefix + schemaVarName := typePrefix + toHandlerName(tool.Name) + "ToolInputSchema" + toolData["InputSchemaVar"] = schemaVarName + + resolvedSchema := tool.InputSchema + if config.IsSchemaRef(tool.InputSchema) && len(tool.InputSchema.Ref) > 0 && tool.InputSchema.Ref[0] == '#' { + resolved, err := g.spec.ResolveSchemaRef(tool.InputSchema.Ref) + if err == nil { + resolvedSchema = resolved + } + } + + schemaCode := g.generateSchemaCode(resolvedSchema) + toolData["InputSchemaCode"] = schemaCode + + hasTypedTools = true + } + + // Add output type information and schema code + if tool.OutputSchema != nil { + outputTypeName := typePrefix + toPascalCase(tool.Name) + "Output" + toolData["OutputType"] = outputTypeName + toolData["HasOutputType"] = true + + // Add schema variable name with proper prefix + schemaVarName := typePrefix + toHandlerName(tool.Name) + "ToolOutputSchema" + toolData["OutputSchemaVar"] = schemaVarName + + resolvedSchema := tool.OutputSchema + if config.IsSchemaRef(tool.OutputSchema) && len(tool.OutputSchema.Ref) > 0 && tool.OutputSchema.Ref[0] == '#' { + resolved, err := g.spec.ResolveSchemaRef(tool.OutputSchema.Ref) + if err == nil { + resolvedSchema = resolved + } + } + + schemaCode := g.generateSchemaCode(resolvedSchema) + toolData["OutputSchemaCode"] = schemaCode + } + + tools = append(tools, toolData) + } + + resources := make([]map[string]interface{}, 0, len(g.spec.Resources)) + for _, resource := range g.spec.Resources { + resData := map[string]interface{}{ + "Name": resource.Name, + "Description": resource.Description, + "HandlerName": toHandlerName(resource.Name), + "MimeType": resource.MimeType, + "Readonly": resource.Readonly, + } + + if resource.URI != "" { + resData["URI"] = resource.URI + } else if resource.URITemplate != "" { + resData["URITemplate"] = resource.URITemplate + params := extractURIParams(resource.URITemplate) + resData["URIParams"] = params + } + + resources = append(resources, resData) + } + + prompts := make([]map[string]interface{}, 0, len(g.spec.Prompts)) + for _, prompt := range g.spec.Prompts { + args := make([]map[string]interface{}, 0, len(prompt.Arguments)) + for _, arg := range prompt.Arguments { + args = append(args, map[string]interface{}{ + "Name": arg.Name, + "Description": arg.Description, + "Required": arg.Required, + }) + } + + promptData := map[string]interface{}{ + "Name": prompt.Name, + "Description": prompt.Description, + "HandlerName": toHandlerName(prompt.Name), + "Arguments": args, + } + + // Add args type if there are arguments + if len(prompt.Arguments) > 0 { + argsTypeName := typePrefix + toPascalCase(prompt.Name) + "Args" + promptData["ArgsType"] = argsTypeName + promptData["HasArgsType"] = true + } + + prompts = append(prompts, promptData) + } + + data := map[string]interface{}{ + "Package": g.config.Exec.Package, + "ServerName": g.spec.Info.Title, + "ServerVersion": g.spec.Info.Version, + "ResolverType": g.config.Resolver.Type, + "Tools": tools, + "Resources": resources, + "Prompts": prompts, + "HasResources": len(resources) > 0, + "HasPrompts": len(prompts) > 0, + "HasTypedTools": hasTypedTools, + } + + // Add imports if packages are different from exec package + var imports []map[string]string + if modelPackage != execPackage && modelImportPath != "" { + imports = append(imports, map[string]string{ + "Path": modelImportPath, + "Alias": "", + }) + } + // Never import resolver package - use interfaces to avoid circular imports + if len(imports) > 0 { + data["Imports"] = imports + } + + return data +} + +func (g *Generator) buildResolverTemplateData() map[string]interface{} { + // Resolver template data is similar to server template data, but uses resolver package + modelPackage := g.config.Model.Package + resolverPackage := g.config.Resolver.Package + typePrefix := "" + modelImportPath := "" + + // Resolver needs to import models if they're in a different package + if modelPackage != resolverPackage { + parts := strings.Split(modelPackage, "/") + typePrefix = parts[len(parts)-1] + "." + + // Compute the full import path for the model package + modelImportPath = g.computeModelImportPath() + } + + tools := make([]map[string]interface{}, 0, len(g.spec.Tools)) + hasTypedTools := false + for _, tool := range g.spec.Tools { + toolData := map[string]interface{}{ + "Name": tool.Name, + "Title": tool.Title, + "Description": tool.Description, + "HandlerName": toHandlerName(tool.Name), + } + + // Add hints if present + if tool.Hints != nil { + toolData["HasHints"] = true + toolData["Readonly"] = tool.Hints.Readonly + toolData["Destructive"] = tool.Hints.Destructive + toolData["Idempotent"] = tool.Hints.Idempotent + toolData["OpenWorld"] = tool.Hints.OpenWorld + } + + // Add input type information + if tool.InputSchema != nil { + inputTypeName := typePrefix + toPascalCase(tool.Name) + "Input" + toolData["InputType"] = inputTypeName + toolData["HasInputType"] = true + hasTypedTools = true + } + + // Add output type information + if tool.OutputSchema != nil { + outputTypeName := typePrefix + toPascalCase(tool.Name) + "Output" + toolData["OutputType"] = outputTypeName + toolData["HasOutputType"] = true + } + + tools = append(tools, toolData) + } + + resources := make([]map[string]interface{}, 0, len(g.spec.Resources)) + for _, resource := range g.spec.Resources { + resData := map[string]interface{}{ + "Name": resource.Name, + "Description": resource.Description, + "HandlerName": toHandlerName(resource.Name), + "MimeType": resource.MimeType, + "Readonly": resource.Readonly, + } + + if resource.URI != "" { + resData["URI"] = resource.URI + } else if resource.URITemplate != "" { + resData["URITemplate"] = resource.URITemplate + params := extractURIParams(resource.URITemplate) + resData["URIParams"] = params + } + + resources = append(resources, resData) + } + + prompts := make([]map[string]interface{}, 0, len(g.spec.Prompts)) + for _, prompt := range g.spec.Prompts { + args := make([]map[string]interface{}, 0, len(prompt.Arguments)) + for _, arg := range prompt.Arguments { + args = append(args, map[string]interface{}{ + "Name": arg.Name, + "Description": arg.Description, + "Required": arg.Required, + }) + } + + promptData := map[string]interface{}{ + "Name": prompt.Name, + "Description": prompt.Description, + "HandlerName": toHandlerName(prompt.Name), + "Arguments": args, + } + + // Add args type if there are arguments + if len(prompt.Arguments) > 0 { + argsTypeName := typePrefix + toPascalCase(prompt.Name) + "Args" + promptData["ArgsType"] = argsTypeName + promptData["HasArgsType"] = true + } + + prompts = append(prompts, promptData) + } + + data := map[string]interface{}{ + "Package": g.config.Resolver.Package, + "ServerName": g.spec.Info.Title, + "ServerVersion": g.spec.Info.Version, + "ResolverType": g.config.Resolver.Type, + "Tools": tools, + "Resources": resources, + "Prompts": prompts, + "HasResources": len(resources) > 0, + "HasPrompts": len(prompts) > 0, + "HasTypedTools": hasTypedTools, + } + + // Add model package import if different from resolver package + if modelPackage != resolverPackage && modelImportPath != "" { + var imports []map[string]string + imports = append(imports, map[string]string{ + "Path": modelImportPath, + "Alias": "", + }) + data["Imports"] = imports + } + + return data +} + +// computeModelImportPath computes the full import path for the model package +func (g *Generator) computeModelImportPath() string { + return g.computeImportPath(g.config.Model.Package, g.config.Model.Filename) +} + +// computeResolverImportPath computes the full import path for the resolver package +func (g *Generator) computeResolverImportPath() string { + return g.computeImportPath(g.config.Resolver.Package, g.config.Resolver.Filename) +} + +// computeImportPath computes the full import path for a package +func (g *Generator) computeImportPath(pkgName, filename string) string { + // If the package is already a full path (contains slashes), use it as-is + if strings.Contains(pkgName, "/") { + return pkgName + } + + // Make output path absolute + absOutput, err := filepath.Abs(g.config.Output) + if err != nil { + return pkgName + } + + // Find the closest go.mod to the output directory + modulePath, moduleRoot, err := findClosestGoMod(absOutput) + if err != nil { + // If we can't read go.mod, fall back to using the package name directly + return pkgName + } + + // Compute the relative path from module root to output directory + relPath, err := filepath.Rel(moduleRoot, absOutput) + if err != nil { + // If we can't compute relative path, fall back to package name + return pkgName + } + + // Compute the import path based on module + relative path + filename dir + // Example: demo + generated + types = demo/generated/types + fileDir := filepath.Dir(filename) + if fileDir == "." { + // If filename has no directory component, the files are in the output root + // Import path should be module + output relative path + return filepath.ToSlash(filepath.Join(modulePath, relPath)) + } + + // Otherwise, use the directory from the filename + return filepath.ToSlash(filepath.Join(modulePath, relPath, fileDir)) +} + +// findClosestGoMod finds the closest go.mod file by walking up from the given directory +// Returns the module path and the directory containing go.mod +func findClosestGoMod(startDir string) (modulePath string, moduleRoot string, err error) { + // Make startDir absolute + absDir, err := filepath.Abs(startDir) + if err != nil { + return "", "", err + } + + // Walk up the directory tree looking for go.mod + currentDir := absDir + for { + goModPath := filepath.Join(currentDir, "go.mod") + if _, err := os.Stat(goModPath); err == nil { + // Found go.mod, parse it using the official modfile package + data, err := os.ReadFile(goModPath) + if err != nil { + return "", "", err + } + + parsed, err := modfile.Parse(goModPath, data, nil) + if err != nil { + return "", "", fmt.Errorf("failed to parse %s: %w", goModPath, err) + } + + if parsed.Module == nil || parsed.Module.Mod.Path == "" { + return "", "", fmt.Errorf("no module directive found in %s", goModPath) + } + + return parsed.Module.Mod.Path, currentDir, nil + } + + // Move up one directory + parent := filepath.Dir(currentDir) + if parent == currentDir { + // Reached the root directory + return "", "", fmt.Errorf("no go.mod found in any parent directory of %s", absDir) + } + currentDir = parent + } +} + +func toHandlerName(name string) string { + parts := strings.FieldsFunc(name, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' + }) + + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + + return strings.Join(parts, "") +} + +func (g *Generator) generateSchemaCode(s *config.Schema) string { + schemaJSON, err := json.Marshal(s) + if err != nil { + return "nil" + } + + return fmt.Sprintf("mustUnmarshalSchema(`%s`)", string(schemaJSON)) +} + +func extractURIParams(uriTemplate string) []map[string]interface{} { + var params []map[string]interface{} + start := -1 + for i, ch := range uriTemplate { + if ch == '{' { + start = i + 1 + } else if ch == '}' && start >= 0 { + paramName := uriTemplate[start:i] + params = append(params, map[string]interface{}{ + "Name": paramName, + "Description": fmt.Sprintf("Parameter from URI template: %s", paramName), + }) + start = -1 + } + } + + return params +} + +func extractGoTypeAnnotation(s *config.Schema) string { + if s == nil || s.Extra == nil { + return "" + } + + if goType, ok := s.Extra["go.probo.inc/mcpgen/type"]; ok { + if goTypeStr, ok := goType.(string); ok { + return goTypeStr + } + } + + return "" +} + +func parseTypeMapping(modelStr string) *CustomTypeMapping { + mapping := &CustomTypeMapping{ + GoType: modelStr, + } + + if strings.Contains(modelStr, "/") { + parts := strings.Split(modelStr, ".") + if len(parts) >= 2 { + typeName := parts[len(parts)-1] + importPath := strings.TrimSuffix(modelStr, "."+typeName) + mapping.GoType = typeName + mapping.ImportPath = importPath + + if strings.Contains(importPath, "/") { + pkgParts := strings.Split(importPath, "/") + pkgAlias := pkgParts[len(pkgParts)-1] + mapping.GoType = pkgAlias + "." + typeName + } + } + } else if strings.Contains(modelStr, ".") { + parts := strings.Split(modelStr, ".") + if len(parts) == 2 { + mapping.ImportPath = parts[0] + mapping.GoType = modelStr + } + } + + return mapping +} diff --git a/third_party/mcpgen/internal/codegen/generator_test.go b/third_party/mcpgen/internal/codegen/generator_test.go new file mode 100644 index 000000000..f383778f1 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/generator_test.go @@ -0,0 +1,1387 @@ +package codegen + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" + + "go.probo.inc/mcpgen/internal/config" +) + +func TestToPascalCase(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"hello", "Hello"}, + {"hello_world", "HelloWorld"}, + {"hello-world", "HelloWorld"}, + {"hello world", "HelloWorld"}, + {"my_api_key", "MyApiKey"}, + {"user-profile-settings", "UserProfileSettings"}, + {"_leading", "Leading"}, + {"trailing_", "Trailing"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toPascalCase(tt.input) + if got != tt.want { + t.Errorf("toPascalCase(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestToHandlerName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"create_task", "CreateTask"}, + {"get-user", "GetUser"}, + {"list items", "ListItems"}, + {"simple", "Simple"}, + {"my_custom_handler", "MyCustomHandler"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toHandlerName(tt.input) + if got != tt.want { + t.Errorf("toHandlerName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestExtractURIParams(t *testing.T) { + tests := []struct { + name string + template string + want []string + }{ + { + name: "single parameter", + template: "users://{id}", + want: []string{"id"}, + }, + { + name: "multiple parameters", + template: "orgs://{orgId}/members/{memberId}", + want: []string{"orgId", "memberId"}, + }, + { + name: "no parameters", + template: "static://resource", + want: []string{}, + }, + { + name: "parameter at start", + template: "{userId}/profile", + want: []string{"userId"}, + }, + { + name: "parameter at end", + template: "users/{id}", + want: []string{"id"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractURIParams(tt.template) + if len(got) != len(tt.want) { + t.Errorf("extractURIParams(%q) returned %d params, want %d", tt.template, len(got), len(tt.want)) + return + } + for i, param := range got { + if paramName, ok := param["Name"].(string); ok { + if paramName != tt.want[i] { + t.Errorf("extractURIParams(%q)[%d] = %q, want %q", tt.template, i, paramName, tt.want[i]) + } + } else { + t.Errorf("extractURIParams(%q)[%d] missing Name field", tt.template, i) + } + } + }) + } +} + +func TestGenerateSchemaCode(t *testing.T) { + gen := &Generator{} + + tests := []struct { + name string + schema *config.Schema + want string + }{ + { + name: "nil schema", + schema: nil, + want: "mustUnmarshalSchema(`null`)", // JSON marshals nil to "null" + }, + { + name: "simple schema", + schema: &config.Schema{ + Type: "string", + }, + want: "mustUnmarshalSchema(`{\"type\":\"string\"}`)", + }, + { + name: "schema with properties", + schema: &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "name": {Type: "string"}, + }, + }, + want: "mustUnmarshalSchema(", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gen.generateSchemaCode(tt.schema) + if !containsString(got, tt.want) { + t.Errorf("generateSchemaCode() = %q, should contain %q", got, tt.want) + } + }) + } +} + +func TestContains(t *testing.T) { + tests := []struct { + name string + slice []string + item string + want bool + }{ + { + name: "item exists", + slice: []string{"foo", "bar", "baz"}, + item: "bar", + want: true, + }, + { + name: "item does not exist", + slice: []string{"foo", "bar", "baz"}, + item: "qux", + want: false, + }, + { + name: "empty slice", + slice: []string{}, + item: "foo", + want: false, + }, + { + name: "nil slice", + slice: nil, + item: "foo", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := contains(tt.slice, tt.item) + if got != tt.want { + t.Errorf("contains(%v, %q) = %v, want %v", tt.slice, tt.item, got, tt.want) + } + }) + } +} + +func TestGetRequiredHandlerNames(t *testing.T) { + spec := &config.MCPSpec{ + Tools: []config.Tool{ + {Name: "create_task"}, + {Name: "update-task"}, + }, + Resources: []config.Resource{ + {Name: "task_resource"}, + }, + Prompts: []config.Prompt{ + {Name: "help_prompt"}, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + got := gen.getRequiredHandlerNames() + + if len(got) != 4 { + t.Errorf("getRequiredHandlerNames() returned %d handlers, want 4", len(got)) + } + + expected := map[string]bool{ + "CreateTaskTool": true, + "UpdateTaskTool": true, + "TaskResourceResource": true, + "HelpPromptPrompt": true, + } + + for _, name := range got { + if !expected[name] { + t.Errorf("Unexpected handler name: %q", name) + } + delete(expected, name) + } + + if len(expected) > 0 { + t.Errorf("Missing handler names: %v", expected) + } +} + +func TestLoadSchemas(t *testing.T) { + specPath := filepath.Join("testdata", "custom_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + cfg := &config.Config{ + Spec: specPath, + Output: t.TempDir(), + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + } + + gen := New(cfg, spec) + + err = gen.loadSchemas() + if err != nil { + t.Fatalf("loadSchemas() error = %v", err) + } + + componentSchemas := []string{"Timestamp", "UUID", "Decimal", "Metadata", "Duration", "Status", "Task", "OptionalFields", "Project"} + for _, schemaName := range componentSchemas { + if _, exists := gen.typeGen.schemas[schemaName]; !exists { + t.Errorf("Expected schema %q to be loaded", schemaName) + } + } + + toolInputs := []string{"CreateTaskInput", "UpdateTaskInput", "CreateProjectInput"} + for _, inputName := range toolInputs { + if _, exists := gen.typeGen.schemas[inputName]; !exists { + t.Errorf("Expected tool input schema %q to be loaded", inputName) + } + } + + resourceSchemas := []string{"TaskResourceContent", "ProjectResourceContent"} + for _, schemaName := range resourceSchemas { + if _, exists := gen.typeGen.schemas[schemaName]; !exists { + t.Errorf("Expected resource schema %q to be loaded", schemaName) + } + } + + promptArgs := []string{"TaskSummaryArgs"} + for _, argsName := range promptArgs { + if _, exists := gen.typeGen.schemas[argsName]; !exists { + t.Errorf("Expected prompt args schema %q to be loaded", argsName) + } + } + + customMappings := []string{"Timestamp", "UUID", "Decimal", "Metadata", "Duration"} + for _, schemaName := range customMappings { + if _, exists := gen.typeGen.customMappings[schemaName]; !exists { + t.Errorf("Expected custom mapping for %q from go.probo.inc/mcpgen/type annotation", schemaName) + } + } +} + +func TestLoadSchemasWithConfigMappings(t *testing.T) { + specPath := filepath.Join("testdata", "config_based_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + cfg := &config.Config{ + Spec: specPath, + Output: t.TempDir(), + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{ + "Timestamp": {Model: "time.Time"}, + "UUID": {Model: "github.com/google/uuid.UUID"}, + "User": {Model: "github.com/myapp/models.User"}, + }, + }, + } + + gen := New(cfg, spec) + + if _, exists := gen.typeGen.customMappings["Timestamp"]; !exists { + t.Error("Expected custom mapping for Timestamp from config") + } + if _, exists := gen.typeGen.customMappings["UUID"]; !exists { + t.Error("Expected custom mapping for UUID from config") + } + if _, exists := gen.typeGen.customMappings["User"]; !exists { + t.Error("Expected custom mapping for User from config") + } + + err = gen.loadSchemas() + if err != nil { + t.Fatalf("loadSchemas() error = %v", err) + } + + expectedSchemas := []string{"Timestamp", "UUID", "User", "Event"} + for _, schemaName := range expectedSchemas { + if _, exists := gen.typeGen.schemas[schemaName]; !exists { + t.Errorf("Expected schema %q to be loaded", schemaName) + } + } +} + +func TestBuildServerTemplateData(t *testing.T) { + spec := &config.MCPSpec{ + Info: config.ServerInfo{ + Title: "test-server", + Version: "1.0.0", + }, + Tools: []config.Tool{ + { + Name: "create_task", + Description: "Create a task", + InputSchema: &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "title": {Type: "string"}, + }, + }, + }, + }, + Resources: []config.Resource{ + { + Name: "task", + URITemplate: "task://{id}", + Description: "Task resource", + }, + }, + Prompts: []config.Prompt{ + { + Name: "help", + Description: "Get help", + Arguments: []config.PromptArgument{ + {Name: "topic", Required: true}, + }, + }, + }, + } + + cfg := &config.Config{ + Model: config.ModelConfig{ + Package: "test", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Type: "Resolver", + }, + } + + gen := New(cfg, spec) + data := gen.buildServerTemplateData() + + if data["ServerName"] != "test-server" { + t.Errorf("ServerName = %v, want test-server", data["ServerName"]) + } + if data["ServerVersion"] != "1.0.0" { + t.Errorf("ServerVersion = %v, want 1.0.0", data["ServerVersion"]) + } + + tools, ok := data["Tools"].([]map[string]interface{}) + if !ok { + t.Fatal("Tools should be []map[string]interface{}") + } + if len(tools) != 1 { + t.Errorf("Expected 1 tool, got %d", len(tools)) + } + if tools[0]["Name"] != "create_task" { + t.Errorf("Tool name = %v, want create_task", tools[0]["Name"]) + } + if tools[0]["HandlerName"] != "CreateTask" { + t.Errorf("Handler name = %v, want CreateTask", tools[0]["HandlerName"]) + } + + resources, ok := data["Resources"].([]map[string]interface{}) + if !ok { + t.Fatal("Resources should be []map[string]interface{}") + } + if len(resources) != 1 { + t.Errorf("Expected 1 resource, got %d", len(resources)) + } + if resources[0]["Name"] != "task" { + t.Errorf("Resource name = %v, want task", resources[0]["Name"]) + } + + prompts, ok := data["Prompts"].([]map[string]interface{}) + if !ok { + t.Fatal("Prompts should be []map[string]interface{}") + } + if len(prompts) != 1 { + t.Errorf("Expected 1 prompt, got %d", len(prompts)) + } + if prompts[0]["Name"] != "help" { + t.Errorf("Prompt name = %v, want help", prompts[0]["Name"]) + } + + if data["HasResources"] != true { + t.Error("HasResources should be true") + } + if data["HasPrompts"] != true { + t.Error("HasPrompts should be true") + } +} + +func TestExtractOrphanedHandlerNames(t *testing.T) { + // Create a temporary file with orphaned handlers section + content := `package test + +// Some code here + +// ============================================================================== +// Orphaned Handlers +// ============================================================================== +// The following handlers were found in the resolver file but are no longer +// defined in the MCP specification. They have been preserved here as comments +// in case you need to reference or restore them. +// ============================================================================== + +// Orphaned: OldHandler +func (r *Resolver) OldHandler() { +} + +// Orphaned: AnotherOldHandler +func (r *Resolver) AnotherOldHandler() { +} +` + + tmpFile := filepath.Join(t.TempDir(), "resolver.go") + if err := writeFile(tmpFile, []byte(content)); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + got := extractOrphanedHandlerNames(tmpFile) + + expected := []string{"OldHandler", "AnotherOldHandler"} + if len(got) != len(expected) { + t.Errorf("extractOrphanedHandlerNames() returned %d names, want %d", len(got), len(expected)) + return + } + + for i, name := range expected { + if got[i] != name { + t.Errorf("extractOrphanedHandlerNames()[%d] = %q, want %q", i, got[i], name) + } + } +} + +func TestExtractOrphanedHandlerNamesNoSection(t *testing.T) { + // Create a file without orphaned section + content := `package test + +func (r *Resolver) NormalHandler() { +} +` + + tmpFile := filepath.Join(t.TempDir(), "resolver.go") + if err := writeFile(tmpFile, []byte(content)); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + got := extractOrphanedHandlerNames(tmpFile) + + if len(got) != 0 { + t.Errorf("extractOrphanedHandlerNames() returned %d names, want 0", len(got)) + } +} + +func writeFile(path string, content []byte) error { + return os.WriteFile(path, content, 0644) +} + +func TestGenerateModels(t *testing.T) { + specPath := filepath.Join("testdata", "custom_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + outputDir := t.TempDir() + cfg := &config.Config{ + Spec: specPath, + Output: outputDir, + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Type: "Resolver", + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + } + + gen := New(cfg, spec) + + // Load schemas first + if err := gen.loadSchemas(); err != nil { + t.Fatalf("loadSchemas() error = %v", err) + } + + if err := gen.generateModels(); err != nil { + t.Fatalf("generateModels() error = %v", err) + } + + modelsPath := filepath.Join(outputDir, "models.go") + if _, err := os.Stat(modelsPath); os.IsNotExist(err) { + t.Errorf("models.go should be created at %s", modelsPath) + } + + content, err := os.ReadFile(modelsPath) + require.NoError(t, err, "Failed to read models.go") + + codeStr := string(content) + + if !containsString(codeStr, "package test") { + t.Error("Generated models should have correct package declaration") + } + + if containsString(codeStr, "type Timestamp") { + t.Error("Should not generate Timestamp type (has go.probo.inc/mcpgen/type)") + } + if containsString(codeStr, "type UUID") { + t.Error("Should not generate UUID type (has go.probo.inc/mcpgen/type)") + } + + if !containsString(codeStr, "type Task struct") { + t.Error("Should generate Task type") + } + + if !containsString(codeStr, `"time"`) { + t.Error("Should import time package") + } + if !containsString(codeStr, `"github.com/google/uuid"`) { + t.Error("Should import uuid package") + } +} + +func TestFullGenerateWorkflow(t *testing.T) { + specPath := filepath.Join("testdata", "config_based_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + outputDir := t.TempDir() + cfg := &config.Config{ + Spec: specPath, + Output: outputDir, + Exec: config.ExecConfig{ + Package: "test", + Filename: "server.go", + }, + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Filename: "resolver.go", + Type: "Resolver", + Preserve: false, + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{ + "Timestamp": {Model: "time.Time"}, + "UUID": {Model: "github.com/google/uuid.UUID"}, + }, + }, + } + + gen := New(cfg, spec) + + if err := gen.Generate(); err != nil { + t.Fatalf("Generate() error = %v", err) + } + + expectedFiles := []string{ + "models.go", + "server.go", + "resolver.go", + "schema.resolvers.go", + } + + for _, filename := range expectedFiles { + filePath := filepath.Join(outputDir, filename) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Expected file %s should be created", filename) + } + } + + modelsContent, err := os.ReadFile(filepath.Join(outputDir, "models.go")) + require.NoError(t, err, "Failed to read models.go") + + modelsStr := string(modelsContent) + if !containsString(modelsStr, "type Event struct") { + t.Error("models.go should contain Event type") + } + + serverContent, err := os.ReadFile(filepath.Join(outputDir, "server.go")) + require.NoError(t, err, "Failed to read server.go") + + serverStr := string(serverContent) + if !containsString(serverStr, "func New(") { + t.Error("server.go should contain New function") + } + if !containsString(serverStr, "mcp.NewServer") { + t.Error("server.go should use MCP SDK") + } + + resolverContent, err := os.ReadFile(filepath.Join(outputDir, "resolver.go")) + require.NoError(t, err, "Failed to read resolver.go") + + resolverStr := string(resolverContent) + if !containsString(resolverStr, "type Resolver struct") { + t.Error("resolver.go should contain Resolver struct") + } + + resolversContent, err := os.ReadFile(filepath.Join(outputDir, "schema.resolvers.go")) + require.NoError(t, err, "Failed to read schema.resolvers.go") + + resolversStr := string(resolversContent) + if !containsString(resolversStr, "func (r *Resolver) CreateEvent") { + t.Error("schema.resolvers.go should contain CreateEvent handler") + } +} + +func TestGenerateWithDifferentPackages(t *testing.T) { + specPath := filepath.Join("testdata", "custom_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + outputDir := t.TempDir() + cfg := &config.Config{ + Spec: specPath, + Output: outputDir, + Exec: config.ExecConfig{ + Package: "server", + Filename: "server.go", + }, + Model: config.ModelConfig{ + Package: "types", + Filename: "types/models.go", + }, + Resolver: config.ResolverConfig{ + Package: "mcp_v1", + Filename: "resolver.go", + Type: "Resolver", + Preserve: false, + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + } + + gen := New(cfg, spec) + + // Load schemas + if err := gen.loadSchemas(); err != nil { + t.Fatalf("loadSchemas() error = %v", err) + } + + data := gen.buildServerTemplateData() + + // When model package differs from exec package, should have imports + imports, ok := data["Imports"] + assert.True(t, ok, "Should have Imports when packages differ") + + if imports != nil { + importList, ok := imports.([]map[string]string) + require.True(t, ok, "Imports should be []map[string]string") + assert.NotEmpty(t, importList, "Imports list should not be empty when packages differ") + } +} + +func TestCountOrphanedHandlers(t *testing.T) { + tests := []struct { + name string + code string + want int + }{ + { + name: "no orphaned handlers", + code: "func (r *Resolver) Handler() {}", + want: 0, + }, + { + name: "one orphaned handler", + code: "// Orphaned: OldHandler\nfunc (r *Resolver) OldHandler() {}", + want: 1, + }, + { + name: "multiple orphaned handlers", + code: "// Orphaned: Handler1\nfunc (r *Resolver) Handler1() {}\n// Orphaned: Handler2\nfunc (r *Resolver) Handler2() {}", + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := countOrphanedHandlers(tt.code) + if got != tt.want { + t.Errorf("countOrphanedHandlers() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestGenerateNewHandlersCode(t *testing.T) { + specPath := filepath.Join("testdata", "config_based_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + cfg := &config.Config{ + Spec: specPath, + Output: t.TempDir(), + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Filename: "resolver.go", + Type: "Resolver", + Preserve: false, + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + } + + gen := New(cfg, spec) + if err := gen.loadSchemas(); err != nil { + t.Fatalf("Failed to load schemas: %v", err) + } + + tests := []struct { + name string + handlerNames []string + wantContains []string + isEmpty bool + }{ + { + name: "empty handler list", + handlerNames: []string{}, + isEmpty: true, + }, + { + name: "single handler", + handlerNames: []string{"CreateEventTool"}, + wantContains: []string{"CreateEventTool", "func (r *Resolver) CreateEventTool"}, + isEmpty: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, err := gen.generateNewHandlersCode(tt.handlerNames) + if err != nil { + t.Errorf("generateNewHandlersCode() error = %v", err) + return + } + + if tt.isEmpty { + if code != "" { + t.Errorf("Expected empty code, got: %q", code) + } + return + } + + for _, want := range tt.wantContains { + if !containsString(code, want) { + t.Errorf("Generated code should contain %q\nGot: %s", want, code) + } + } + }) + } +} + +func TestResolveAllRefs(t *testing.T) { + spec := &config.MCPSpec{ + Components: config.Components{ + Schemas: map[string]*config.Schema{ + "SimpleType": { + Type: "string", + Format: "uuid", + }, + "ComplexType": { + Type: "object", + Properties: map[string]*config.Schema{ + "id": { + Ref: "#/components/schemas/SimpleType", + }, + "name": { + Type: "string", + }, + }, + }, + "WithExtensions": { + Type: "string", + Format: "date-time", + Extra: map[string]interface{}{ + "go.probo.inc/mcpgen/type": "time.Time", + "x-custom-field": "value", + }, + }, + "NestedRef": { + Type: "object", + Properties: map[string]*config.Schema{ + "complex": { + Ref: "#/components/schemas/ComplexType", + }, + }, + }, + }, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + tests := []struct { + name string + schema *config.Schema + wantType string + wantNoRef bool + wantNoExtras bool + checkProperty string + propertyType string + }{ + { + name: "nil schema", + schema: nil, + wantType: "", + wantNoRef: true, + }, + { + name: "simple ref resolution", + schema: &config.Schema{ + Ref: "#/components/schemas/SimpleType", + }, + wantType: "string", + wantNoRef: true, + }, + { + name: "nested ref in properties", + schema: &config.Schema{ + Ref: "#/components/schemas/ComplexType", + }, + wantType: "object", + wantNoRef: true, + checkProperty: "id", + propertyType: "string", + }, + { + name: "deep nested refs", + schema: &config.Schema{ + Ref: "#/components/schemas/NestedRef", + }, + wantType: "object", + wantNoRef: true, + checkProperty: "complex", + propertyType: "object", + }, + { + name: "ref in array items", + schema: &config.Schema{ + Type: "array", + Items: &config.Schema{ + Ref: "#/components/schemas/SimpleType", + }, + }, + wantType: "array", + wantNoRef: true, + }, + { + name: "ref in anyOf", + schema: &config.Schema{ + AnyOf: []*config.Schema{ + { + Ref: "#/components/schemas/SimpleType", + }, + { + Type: "null", + }, + }, + }, + wantNoRef: true, + }, + { + name: "removes x-* extensions", + schema: &config.Schema{ + Ref: "#/components/schemas/WithExtensions", + }, + wantType: "string", + wantNoRef: true, + wantNoExtras: true, + }, + { + name: "no ref to resolve", + schema: &config.Schema{ + Type: "string", + }, + wantType: "string", + wantNoRef: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := gen.resolveAllRefs(tt.schema) + if err != nil { + t.Fatalf("resolveAllRefs() error = %v", err) + } + + if tt.schema == nil { + if got != nil { + t.Errorf("resolveAllRefs(nil) should return nil") + } + return + } + + if tt.wantType != "" && got.Type != tt.wantType { + t.Errorf("Type = %q, want %q", got.Type, tt.wantType) + } + + if tt.wantNoRef && got.Ref != "" { + t.Errorf("Ref should be empty, got %q", got.Ref) + } + + if tt.wantNoExtras && got.Extra != nil && len(got.Extra) > 0 { + t.Errorf("Extra fields should be removed, got %v", got.Extra) + } + + if tt.checkProperty != "" { + if got.Properties == nil { + t.Error("Properties should not be nil") + return + } + prop, ok := got.Properties[tt.checkProperty] + if !ok { + t.Errorf("Property %q should exist", tt.checkProperty) + return + } + if prop.Type != tt.propertyType { + t.Errorf("Property %q type = %q, want %q", tt.checkProperty, prop.Type, tt.propertyType) + } + if prop.Ref != "" { + t.Errorf("Property %q should have ref resolved, got %q", tt.checkProperty, prop.Ref) + } + } + + if tt.schema.Type == "array" && got.Items != nil { + if got.Items.Ref != "" { + t.Errorf("Array items should have ref resolved, got %q", got.Items.Ref) + } + } + + if len(tt.schema.AnyOf) > 0 { + for i, schema := range got.AnyOf { + if schema.Ref != "" { + t.Errorf("anyOf[%d] should have ref resolved, got %q", i, schema.Ref) + } + } + } + }) + } +} + +func TestResolveAllRefsInAllOf(t *testing.T) { + spec := &config.MCPSpec{ + Components: config.Components{ + Schemas: map[string]*config.Schema{ + "Base": { + Type: "object", + Properties: map[string]*config.Schema{ + "id": {Type: "string"}, + }, + }, + "Extended": { + Type: "object", + Properties: map[string]*config.Schema{ + "name": {Type: "string"}, + }, + }, + }, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + schema := &config.Schema{ + AllOf: []*config.Schema{ + {Ref: "#/components/schemas/Base"}, + {Ref: "#/components/schemas/Extended"}, + }, + } + + got, err := gen.resolveAllRefs(schema) + if err != nil { + t.Fatalf("resolveAllRefs() error = %v", err) + } + + if len(got.AllOf) != 2 { + t.Errorf("AllOf length = %d, want 2", len(got.AllOf)) + } + + for i, s := range got.AllOf { + if s.Ref != "" { + t.Errorf("allOf[%d] should have ref resolved, got %q", i, s.Ref) + } + if s.Type != "object" { + t.Errorf("allOf[%d] type = %q, want object", i, s.Type) + } + } +} + +func TestResolveAllRefsInOneOf(t *testing.T) { + spec := &config.MCPSpec{ + Components: config.Components{ + Schemas: map[string]*config.Schema{ + "Option1": {Type: "string"}, + "Option2": {Type: "number"}, + }, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + schema := &config.Schema{ + OneOf: []*config.Schema{ + {Ref: "#/components/schemas/Option1"}, + {Ref: "#/components/schemas/Option2"}, + }, + } + + got, err := gen.resolveAllRefs(schema) + if err != nil { + t.Fatalf("resolveAllRefs() error = %v", err) + } + + if len(got.OneOf) != 2 { + t.Errorf("OneOf length = %d, want 2", len(got.OneOf)) + } + + for i, s := range got.OneOf { + if s.Ref != "" { + t.Errorf("oneOf[%d] should have ref resolved, got %q", i, s.Ref) + } + } +} + +func TestResolveAllRefsInAdditionalProperties(t *testing.T) { + spec := &config.MCPSpec{ + Components: config.Components{ + Schemas: map[string]*config.Schema{ + "Value": {Type: "string"}, + }, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + schema := &config.Schema{ + Type: "object", + AdditionalProperties: &config.Schema{ + Ref: "#/components/schemas/Value", + }, + } + + got, err := gen.resolveAllRefs(schema) + if err != nil { + t.Fatalf("resolveAllRefs() error = %v", err) + } + + if got.AdditionalProperties == nil { + t.Fatal("AdditionalProperties should not be nil") + } + + if got.AdditionalProperties.Ref != "" { + t.Errorf("AdditionalProperties should have ref resolved, got %q", got.AdditionalProperties.Ref) + } + + if got.AdditionalProperties.Type != "string" { + t.Errorf("AdditionalProperties type = %q, want string", got.AdditionalProperties.Type) + } +} + +func TestResolveAllRefsInPatternProperties(t *testing.T) { + spec := &config.MCPSpec{ + Components: config.Components{ + Schemas: map[string]*config.Schema{ + "Pattern": {Type: "number"}, + }, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + schema := &config.Schema{ + Type: "object", + PatternProperties: map[string]*config.Schema{ + "^[a-z]+$": { + Ref: "#/components/schemas/Pattern", + }, + }, + } + + got, err := gen.resolveAllRefs(schema) + if err != nil { + t.Fatalf("resolveAllRefs() error = %v", err) + } + + if got.PatternProperties == nil { + t.Fatal("PatternProperties should not be nil") + } + + pattern, ok := got.PatternProperties["^[a-z]+$"] + if !ok { + t.Fatal("Pattern should exist") + } + + if pattern.Ref != "" { + t.Errorf("Pattern should have ref resolved, got %q", pattern.Ref) + } + + if pattern.Type != "number" { + t.Errorf("Pattern type = %q, want number", pattern.Type) + } +} + +func TestResolveAllRefsError(t *testing.T) { + spec := &config.MCPSpec{ + Components: config.Components{ + Schemas: map[string]*config.Schema{}, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + schema := &config.Schema{ + Ref: "#/components/schemas/NonExistent", + } + + _, err := gen.resolveAllRefs(schema) + if err == nil { + t.Error("resolveAllRefs() should return error for non-existent ref") + } +} + +func TestResolveAllRefsRemovesExtensions(t *testing.T) { + spec := &config.MCPSpec{ + Components: config.Components{ + Schemas: map[string]*config.Schema{ + "TypeWithExtensions": { + Type: "object", + Extra: map[string]interface{}{ + "go.probo.inc/mcpgen/type": "custom.Type", + "x-custom": "value", + }, + Properties: map[string]*config.Schema{ + "field": { + Type: "string", + Extra: map[string]interface{}{ + "x-validation": "required", + }, + }, + }, + }, + }, + }, + } + + cfg := &config.Config{} + gen := New(cfg, spec) + + schema := &config.Schema{ + Ref: "#/components/schemas/TypeWithExtensions", + } + + got, err := gen.resolveAllRefs(schema) + if err != nil { + t.Fatalf("resolveAllRefs() error = %v", err) + } + + if got.Extra != nil && len(got.Extra) > 0 { + t.Errorf("Extra should be removed from resolved schema, got %v", got.Extra) + } + + if got.Properties == nil { + t.Fatal("Properties should not be nil") + } + + field := got.Properties["field"] + if field.Extra != nil && len(field.Extra) > 0 { + t.Errorf("Extra should be removed from property, got %v", field.Extra) + } +} + +func TestUpdateResolverIncremental(t *testing.T) { + specPath := filepath.Join("testdata", "config_based_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + tests := []struct { + name string + existingResolver string + wantContains []string + wantNotContains []string + expectUpdate bool + }{ + { + name: "add new handler", + existingResolver: `package test + +import ( + "context" + "fmt" + mcp "github.com/mark3labs/mcp-go/mcp" +) + +type Resolver struct{} + +type toolResolver struct { + *Resolver +} + +type promptResolver struct { + *Resolver +} + +type resourceResolver struct { + *Resolver +} + +func (r *toolResolver) OldHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, map[string]any, error) { + return nil, nil, nil +} +`, + wantContains: []string{ + "func (r *Resolver) CreateEventTool", + "Orphaned: OldHandler", + }, + expectUpdate: true, + }, + { + name: "no changes needed", + existingResolver: `package test + +import ( + "context" + "fmt" + mcp "github.com/mark3labs/mcp-go/mcp" +) + +type Resolver struct{} + +type toolResolver struct { + *Resolver +} + +type promptResolver struct { + *Resolver +} + +type resourceResolver struct { + *Resolver +} + +func (r *Resolver) CreateEventTool(ctx context.Context, req *mcp.CallToolRequest, input *Event) (*mcp.CallToolResult, map[string]any, error) { + return nil, nil, fmt.Errorf("create-event not implemented") +} +`, + wantContains: []string{ + "func (r *Resolver) CreateEventTool", + }, + wantNotContains: []string{ + "Orphaned Handlers", + }, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + resolverFile := filepath.Join(tmpDir, "schema.resolvers.go") + + if err := os.WriteFile(resolverFile, []byte(tt.existingResolver), 0644); err != nil { + t.Fatalf("Failed to write resolver file: %v", err) + } + + cfg := &config.Config{ + Spec: specPath, + Output: tmpDir, + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Filename: "schema.resolvers.go", + Type: "Resolver", + Preserve: true, + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + } + + gen := New(cfg, spec) + if err := gen.loadSchemas(); err != nil { + t.Fatalf("Failed to load schemas: %v", err) + } + + err := gen.updateResolverIncremental(resolverFile) + if err != nil { + t.Fatalf("updateResolverIncremental() error = %v", err) + } + + content, err := os.ReadFile(resolverFile) + if err != nil { + t.Fatalf("Failed to read updated resolver: %v", err) + } + + contentStr := string(content) + + for _, want := range tt.wantContains { + if !containsString(contentStr, want) { + t.Errorf("Updated resolver should contain %q\nGot: %s", want, contentStr) + } + } + + for _, wantNot := range tt.wantNotContains { + if containsString(contentStr, wantNot) { + t.Errorf("Updated resolver should NOT contain %q", wantNot) + } + } + }) + } +} + diff --git a/third_party/mcpgen/internal/codegen/integration_test.go b/third_party/mcpgen/internal/codegen/integration_test.go new file mode 100644 index 000000000..f7a261ba5 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/integration_test.go @@ -0,0 +1,282 @@ +package codegen + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.probo.inc/mcpgen/internal/config" +) + +func TestGenerateWithCustomTypes(t *testing.T) { + specPath := filepath.Join("testdata", "custom_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + cfg := &config.Config{ + Spec: specPath, + Output: t.TempDir(), + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Filename: "resolver.go", + Type: "Resolver", + Preserve: false, + }, + // No custom models in config - using go.probo.inc/mcpgen/type annotations + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + } + + gen := New(cfg, spec) + + if err := gen.loadSchemas(); err != nil { + t.Fatalf("Failed to load schemas: %v", err) + } + + code, err := gen.typeGen.Generate("test") + require.NoError(t, err, "Failed to generate code") + + codeStr := string(code) + + customTypes := []string{"Timestamp", "UUID", "Decimal", "Metadata", "Duration"} + for _, typeName := range customTypes { + assert.NotContains(t, codeStr, "type "+typeName+" ") + } + + regularTypes := []string{"Task", "OptionalFields", "Project", "UpdateTaskInput"} + for _, typeName := range regularTypes { + assert.Contains(t, codeStr, "type "+typeName) + } + + expectedImports := []string{ + "time", + "github.com/google/uuid", + "github.com/shopspring/decimal", + "json", + "go.probo.inc/mcpgen/mcp", + } + for _, imp := range expectedImports { + assert.Contains(t, codeStr, `"`+imp+`"`) + } + + assert.Contains(t, codeStr, "ID uuid.UUID") + assert.Contains(t, codeStr, "CreatedAt time.Time") + assert.Contains(t, codeStr, "UpdatedAt *time.Time") + assert.Contains(t, codeStr, "mcp.Omittable[*string]") + assert.Contains(t, codeStr, "mcp.Omittable[*Status]") + assert.Contains(t, codeStr, "mcp.Omittable[*int]") + assert.Contains(t, codeStr, "mcp.Omittable[*[]string]") +} + +func TestGenerateWithConfigBasedTypes(t *testing.T) { + specPath := filepath.Join("testdata", "config_based_types.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + cfg := &config.Config{ + Spec: specPath, + Output: t.TempDir(), + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Filename: "resolver.go", + Type: "Resolver", + Preserve: false, + }, + // Custom models in config + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{ + "Timestamp": {Model: "time.Time"}, + "UUID": {Model: "github.com/google/uuid.UUID"}, + "User": {Model: "github.com/myapp/models.User"}, + }, + }, + } + + gen := New(cfg, spec) + + if err := gen.loadSchemas(); err != nil { + t.Fatalf("Failed to load schemas: %v", err) + } + + code, err := gen.typeGen.Generate("test") + require.NoError(t, err, "Failed to generate code") + + codeStr := string(code) + + customTypes := []string{"Timestamp", "UUID", "User"} + for _, typeName := range customTypes { + assert.NotContains(t, codeStr, "type "+typeName+" ") + } + + assert.Contains(t, codeStr, "type Event struct") + + expectedImports := []string{ + "time", + "github.com/google/uuid", + "github.com/myapp/models", + } + for _, imp := range expectedImports { + assert.Contains(t, codeStr, `"`+imp+`"`) + } + + assert.Contains(t, codeStr, "ID uuid.UUID") + assert.Contains(t, codeStr, "CreatedAt time.Time") + assert.Contains(t, codeStr, "Owner *models.User") +} + +func TestGenerateAllPrimitives(t *testing.T) { + specPath := filepath.Join("testdata", "all_primitives.yaml") + spec, err := config.LoadMCPSpec(specPath) + require.NoError(t, err, "Failed to load spec") + + cfg := &config.Config{ + Spec: specPath, + Output: t.TempDir(), + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Resolver: config.ResolverConfig{ + Package: "test", + Filename: "resolver.go", + Type: "Resolver", + Preserve: false, + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + } + + gen := New(cfg, spec) + + if err := gen.loadSchemas(); err != nil { + t.Fatalf("Failed to load schemas: %v", err) + } + + code, err := gen.typeGen.Generate("test") + require.NoError(t, err, "Failed to generate code") + + codeStr := string(code) + + primitiveTypes := map[string]string{ + "StringSchema": "type StringSchema string", + "NumberSchema": "type NumberSchema float64", + "IntegerSchema": "type IntegerSchema int", + "BooleanSchema": "type BooleanSchema bool", + "ArraySchema": "type ArraySchema []string", + } + + for typeName, expectedDecl := range primitiveTypes { + if !containsString(codeStr, expectedDecl) { + t.Errorf("Should generate %q for %s", expectedDecl, typeName) + } + } + + if !containsString(codeStr, "type ObjectSchema struct") { + t.Error("Should generate ObjectSchema as a struct") + } + + if !containsString(codeStr, "type Person struct") { + t.Error("Should generate Person as a struct") + } + + if !containsString(codeStr, "type Color string") { + t.Error("Should generate Color as string-based enum") + } + + enumConstants := []string{"ColorRed", "ColorGreen", "ColorBlue", "ColorYellow"} + for _, constName := range enumConstants { + if !containsString(codeStr, constName) { + t.Errorf("Should generate enum constant %q", constName) + } + } + + if len(code) == 0 { + t.Error("Generated code is empty") + } +} + +func TestGeneratedCodeCompiles(t *testing.T) { + testCases := []struct { + name string + specFile string + config *config.Config + }{ + { + name: "custom_types", + specFile: "custom_types.yaml", + config: &config.Config{ + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{}, + }, + }, + }, + { + name: "config_based_types", + specFile: "config_based_types.yaml", + config: &config.Config{ + Model: config.ModelConfig{ + Package: "test", + Filename: "models.go", + }, + Models: config.ModelsConfig{ + Models: map[string]config.TypeMapping{ + "Timestamp": {Model: "time.Time"}, + "UUID": {Model: "github.com/google/uuid.UUID"}, + "User": {Model: "github.com/myapp/models.User"}, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + specPath := filepath.Join("testdata", tc.specFile) + spec, err := config.LoadMCPSpec(specPath) + if err != nil { + t.Fatalf("Failed to load spec: %v", err) + } + + tc.config.Spec = specPath + tc.config.Output = t.TempDir() + tc.config.Resolver = config.ResolverConfig{ + Package: "test", + Filename: "resolver.go", + Type: "Resolver", + Preserve: false, + } + + gen := New(tc.config, spec) + if err := gen.loadSchemas(); err != nil { + t.Fatalf("Failed to load schemas: %v", err) + } + + code, err := gen.typeGen.Generate("test") + if err != nil { + t.Fatalf("Failed to generate code: %v", err) + } + + // The fact that Generate() succeeded means the code was formatted successfully + if len(code) == 0 { + t.Error("Generated code is empty") + } + }) + } +} + diff --git a/third_party/mcpgen/internal/codegen/parser.go b/third_party/mcpgen/internal/codegen/parser.go new file mode 100644 index 000000000..e0c32cef3 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/parser.go @@ -0,0 +1,191 @@ +package codegen + +import ( + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "strings" +) + +type HandlerInfo struct { + Name string + RecvType string + SourceCode string + IsOrphaned bool +} + +type ResolverParser struct { + filePath string + fset *token.FileSet + file *ast.File +} + +func NewResolverParser(filePath string) (*ResolverParser, error) { + fset := token.NewFileSet() + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil, fmt.Errorf("resolver file not found: %s", filePath) + } + + file, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("failed to parse resolver file: %w", err) + } + + return &ResolverParser{ + filePath: filePath, + fset: fset, + file: file, + }, nil +} + +func (p *ResolverParser) ExtractHandlers(resolverType string) (map[string]*HandlerInfo, error) { + handlers := make(map[string]*HandlerInfo) + + // Extract from both old wrapper types and new direct Resolver type + allowedTypes := map[string]bool{ + // Old wrapper types (for backward compatibility during migration) + "toolResolver": true, + "*toolResolver": true, + "promptResolver": true, + "*promptResolver": true, + "resourceResolver": true, + "*resourceResolver": true, + // New direct Resolver type + resolverType: true, + "*" + resolverType: true, + } + + for _, decl := range p.file.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + + if funcDecl.Recv == nil { + continue + } + + recvType := p.getReceiverType(funcDecl.Recv) + if !allowedTypes[recvType] { + continue + } + + methodName := funcDecl.Name.Name + sourceCode, err := p.extractFunctionSource(funcDecl) + if err != nil { + return nil, fmt.Errorf("failed to extract source for %s: %w", methodName, err) + } + + // Transform receiver type from old wrapper types to main Resolver type + sourceCode = TransformReceiverType(sourceCode, resolverType) + + handlers[methodName] = &HandlerInfo{ + Name: methodName, + RecvType: "*" + resolverType, // Always use main Resolver type + SourceCode: sourceCode, + IsOrphaned: false, + } + } + + return handlers, nil +} + +func (p *ResolverParser) getReceiverType(recv *ast.FieldList) string { + if recv == nil || len(recv.List) == 0 { + return "" + } + + field := recv.List[0] + switch typ := field.Type.(type) { + case *ast.Ident: + return typ.Name + case *ast.StarExpr: + if ident, ok := typ.X.(*ast.Ident); ok { + return "*" + ident.Name + } + } + + return "" +} + +func (p *ResolverParser) extractFunctionSource(funcDecl *ast.FuncDecl) (string, error) { + var buf strings.Builder + + cfg := printer.Config{ + Mode: printer.UseSpaces | printer.TabIndent, + Tabwidth: 8, + } + + if err := cfg.Fprint(&buf, p.fset, funcDecl); err != nil { + return "", err + } + + return buf.String(), nil +} + +// TransformReceiverType rewrites the receiver type in handler source code from old wrapper types +// (toolResolver, promptResolver, resourceResolver) to the main Resolver type +func TransformReceiverType(sourceCode, resolverType string) string { + // Replace old wrapper types with main Resolver type + sourceCode = strings.ReplaceAll(sourceCode, "*toolResolver)", "*"+resolverType+")") + sourceCode = strings.ReplaceAll(sourceCode, "*promptResolver)", "*"+resolverType+")") + sourceCode = strings.ReplaceAll(sourceCode, "*resourceResolver)", "*"+resolverType+")") + return sourceCode +} + +func IdentifyOrphanedHandlers(existingHandlers map[string]*HandlerInfo, requiredHandlers []string) { + requiredSet := make(map[string]bool) + for _, name := range requiredHandlers { + requiredSet[name] = true + } + + for name, handler := range existingHandlers { + if !requiredSet[name] { + handler.IsOrphaned = true + } + } +} + +func FormatOrphanedHandlers(handlers map[string]*HandlerInfo) string { + var orphaned []*HandlerInfo + + for _, handler := range handlers { + if handler.IsOrphaned { + orphaned = append(orphaned, handler) + } + } + + if len(orphaned) == 0 { + return "" + } + + var buf strings.Builder + buf.WriteString("\n\n// ==============================================================================\n") + buf.WriteString("// Orphaned Handlers\n") + buf.WriteString("// ==============================================================================\n") + buf.WriteString("// The following handlers were found in the resolver file but are no longer\n") + buf.WriteString("// defined in the MCP specification. They have been preserved here as comments\n") + buf.WriteString("// in case you need to reference or restore them.\n") + buf.WriteString("// ==============================================================================\n\n") + + for _, handler := range orphaned { + buf.WriteString(fmt.Sprintf("// Orphaned: %s\n", handler.Name)) + buf.WriteString("// Uncomment and update signature if you want to restore this handler.\n") + + lines := strings.Split(handler.SourceCode, "\n") + for _, line := range lines { + if strings.TrimSpace(line) != "" { + buf.WriteString("// ") + } + buf.WriteString(line) + buf.WriteString("\n") + } + buf.WriteString("\n") + } + + return buf.String() +} diff --git a/third_party/mcpgen/internal/codegen/parser_test.go b/third_party/mcpgen/internal/codegen/parser_test.go new file mode 100644 index 000000000..96a3698c0 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/parser_test.go @@ -0,0 +1,484 @@ +package codegen + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" +) + +func TestNewResolverParser(t *testing.T) { + tests := []struct { + name string + content string + wantErr bool + setupFile bool + }{ + { + name: "valid resolver file", + content: `package test + +type Resolver struct{} + +func (r *Resolver) GetUser(ctx context.Context) error { + return nil +}`, + setupFile: true, + wantErr: false, + }, + { + name: "non-existent file", + content: "", + setupFile: false, + wantErr: true, + }, + { + name: "invalid Go syntax", + content: `package test +func ( { // invalid syntax +}`, + setupFile: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var testFile string + if tt.setupFile { + tmpDir := t.TempDir() + testFile = filepath.Join(tmpDir, "resolver.go") + if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + } else { + testFile = filepath.Join(t.TempDir(), "nonexistent.go") + } + + parser, err := NewResolverParser(testFile) + + if tt.wantErr { + if err == nil { + t.Error("Expected error but got nil") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if parser == nil { + t.Error("Expected parser but got nil") + } + } + }) + } +} + +func TestExtractHandlers(t *testing.T) { + tests := []struct { + name string + content string + resolverType string + wantHandlers []string + wantErr bool + }{ + { + name: "extract toolResolver handlers", + content: `package test + +type toolResolver struct{} + +func (r *toolResolver) HandleListTasks(ctx context.Context) error { + return nil +} + +func (r *toolResolver) HandleCreateTask(ctx context.Context) error { + return nil +} + +// Not a handler - no receiver +func HelperFunction() {} +`, + resolverType: "Resolver", + wantHandlers: []string{"HandleListTasks", "HandleCreateTask"}, + wantErr: false, + }, + { + name: "extract promptResolver handlers", + content: `package test + +type promptResolver struct{} + +func (r *promptResolver) HandleGetPrompt(ctx context.Context) error { + return nil +} +`, + resolverType: "Resolver", + wantHandlers: []string{"HandleGetPrompt"}, + wantErr: false, + }, + { + name: "extract resourceResolver handlers", + content: `package test + +type resourceResolver struct{} + +func (r *resourceResolver) HandleReadResource(ctx context.Context) error { + return nil +} +`, + resolverType: "Resolver", + wantHandlers: []string{"HandleReadResource"}, + wantErr: false, + }, + { + name: "mixed resolver types", + content: `package test + +type toolResolver struct{} +type promptResolver struct{} +type resourceResolver struct{} + +func (r *toolResolver) HandleTool(ctx context.Context) error { + return nil +} + +func (r *promptResolver) HandlePrompt(ctx context.Context) error { + return nil +} + +func (r *resourceResolver) HandleResource(ctx context.Context) error { + return nil +} + +type OtherType struct{} + +func (r *OtherType) NotAHandler(ctx context.Context) error { + return nil +} +`, + resolverType: "Resolver", + wantHandlers: []string{"HandleTool", "HandlePrompt", "HandleResource"}, + wantErr: false, + }, + { + name: "no handlers", + content: `package test + +type Resolver struct{} + +func HelperFunction() {} +`, + resolverType: "Resolver", + wantHandlers: []string{}, + wantErr: false, + }, + { + name: "pointer and value receivers", + content: `package test + +type toolResolver struct{} + +func (r toolResolver) HandleNonPointer(ctx context.Context) error { + return nil +} + +func (r *toolResolver) HandlePointer(ctx context.Context) error { + return nil +} +`, + resolverType: "Resolver", + wantHandlers: []string{"HandleNonPointer", "HandlePointer"}, // Both are accepted + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "resolver.go") + if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + parser, err := NewResolverParser(testFile) + if err != nil { + t.Fatalf("Failed to create parser: %v", err) + } + + handlers, err := parser.ExtractHandlers(tt.resolverType) + + if tt.wantErr { + if err == nil { + t.Error("Expected error but got nil") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(handlers) != len(tt.wantHandlers) { + t.Errorf("Expected %d handlers, got %d", len(tt.wantHandlers), len(handlers)) + } + + for _, wantName := range tt.wantHandlers { + if _, ok := handlers[wantName]; !ok { + t.Errorf("Expected handler %q not found", wantName) + } + } + + for name, handler := range handlers { + if handler.Name != name { + t.Errorf("Handler name mismatch: got %q, want %q", handler.Name, name) + } + if handler.SourceCode == "" { + t.Errorf("Handler %q has empty source code", name) + } + if !strings.Contains(handler.RecvType, "Resolver") { + t.Errorf("Handler %q has unexpected receiver type: %q", name, handler.RecvType) + } + } + }) + } +} + +func TestIdentifyOrphanedHandlers(t *testing.T) { + tests := []struct { + name string + existingHandlers map[string]*HandlerInfo + requiredHandlers []string + wantOrphaned []string + }{ + { + name: "no orphaned handlers", + existingHandlers: map[string]*HandlerInfo{ + "HandleA": {Name: "HandleA"}, + "HandleB": {Name: "HandleB"}, + }, + requiredHandlers: []string{"HandleA", "HandleB"}, + wantOrphaned: []string{}, + }, + { + name: "one orphaned handler", + existingHandlers: map[string]*HandlerInfo{ + "HandleA": {Name: "HandleA"}, + "HandleB": {Name: "HandleB"}, + "HandleC": {Name: "HandleC"}, + }, + requiredHandlers: []string{"HandleA", "HandleB"}, + wantOrphaned: []string{"HandleC"}, + }, + { + name: "all orphaned", + existingHandlers: map[string]*HandlerInfo{ + "HandleA": {Name: "HandleA"}, + "HandleB": {Name: "HandleB"}, + }, + requiredHandlers: []string{}, + wantOrphaned: []string{"HandleA", "HandleB"}, + }, + { + name: "new handlers required", + existingHandlers: map[string]*HandlerInfo{ + "HandleA": {Name: "HandleA"}, + }, + requiredHandlers: []string{"HandleA", "HandleB", "HandleC"}, + wantOrphaned: []string{}, + }, + { + name: "empty existing handlers", + existingHandlers: map[string]*HandlerInfo{}, + requiredHandlers: []string{"HandleA"}, + wantOrphaned: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + IdentifyOrphanedHandlers(tt.existingHandlers, tt.requiredHandlers) + + var gotOrphaned []string + for name, handler := range tt.existingHandlers { + if handler.IsOrphaned { + gotOrphaned = append(gotOrphaned, name) + } + } + + if len(gotOrphaned) != len(tt.wantOrphaned) { + t.Errorf("Expected %d orphaned handlers, got %d", len(tt.wantOrphaned), len(gotOrphaned)) + } + + orphanedSet := make(map[string]bool) + for _, name := range gotOrphaned { + orphanedSet[name] = true + } + + for _, wantName := range tt.wantOrphaned { + if !orphanedSet[wantName] { + t.Errorf("Expected %q to be orphaned but it wasn't", wantName) + } + } + }) + } +} + +func TestFormatOrphanedHandlers(t *testing.T) { + tests := []struct { + name string + handlers map[string]*HandlerInfo + wantContains []string + isEmpty bool + }{ + { + name: "single orphaned handler", + handlers: map[string]*HandlerInfo{ + "HandleOldTask": { + Name: "HandleOldTask", + IsOrphaned: true, + SourceCode: `func (r *Resolver) HandleOldTask(ctx context.Context) error { + return nil +}`, + }, + }, + wantContains: []string{ + "Orphaned Handlers", + "Orphaned: HandleOldTask", + "// func (r *Resolver) HandleOldTask", + }, + isEmpty: false, + }, + { + name: "multiple orphaned handlers", + handlers: map[string]*HandlerInfo{ + "HandleA": { + Name: "HandleA", + IsOrphaned: true, + SourceCode: "func (r *Resolver) HandleA() {}", + }, + "HandleB": { + Name: "HandleB", + IsOrphaned: true, + SourceCode: "func (r *Resolver) HandleB() {}", + }, + }, + wantContains: []string{ + "Orphaned: HandleA", + "Orphaned: HandleB", + }, + isEmpty: false, + }, + { + name: "no orphaned handlers", + handlers: map[string]*HandlerInfo{ + "HandleActive": { + Name: "HandleActive", + IsOrphaned: false, + SourceCode: "func (r *Resolver) HandleActive() {}", + }, + }, + wantContains: []string{}, + isEmpty: true, + }, + { + name: "empty handlers map", + handlers: map[string]*HandlerInfo{}, + wantContains: []string{}, + isEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatOrphanedHandlers(tt.handlers) + + if tt.isEmpty { + if result != "" { + t.Errorf("Expected empty result, got: %q", result) + } + return + } + + if result == "" { + t.Error("Expected non-empty result but got empty string") + return + } + + for _, want := range tt.wantContains { + if !strings.Contains(result, want) { + t.Errorf("Result should contain %q but doesn't.\nGot: %s", want, result) + } + } + + assert.Contains(t, result, "Orphaned Handlers", "Result should contain orphaned handlers header") + }) + } +} + +func TestGetReceiverType(t *testing.T) { + // but we can add a direct test using a sample AST if needed + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + + content := `package test + +type toolResolver struct{} + +func (r *toolResolver) Method() {} +` + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + parser, err := NewResolverParser(testFile) + require.NoError(t, err, "Failed to create parser") + + handlers, err := parser.ExtractHandlers("Resolver") + require.NoError(t, err, "Failed to extract handlers") + + if len(handlers) != 1 { + t.Fatalf("Expected 1 handler, got %d", len(handlers)) + } + + handler := handlers["Method"] + // After transformation, the receiver type should be *Resolver + if handler.RecvType != "*Resolver" { + t.Errorf("Expected receiver type '*Resolver' (after transformation), got %q", handler.RecvType) + } +} + +func TestExtractFunctionSource(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.go") + + content := `package test + +type toolResolver struct{} + +func (r *toolResolver) HandleTest(ctx context.Context) error { + x := 42 + return nil +} +` + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + parser, err := NewResolverParser(testFile) + require.NoError(t, err, "Failed to create parser") + + handlers, err := parser.ExtractHandlers("Resolver") + require.NoError(t, err, "Failed to extract handlers") + + handler := handlers["HandleTest"] + // After transformation, toolResolver should be changed to Resolver + if !strings.Contains(handler.SourceCode, "func (r *Resolver) HandleTest") { + t.Errorf("Source code should contain transformed function signature, got: %s", handler.SourceCode) + } + assert.Contains(t, handler.SourceCode, "return nil", "Source code should contain function body") + assert.Contains(t, handler.SourceCode, "x := 42", "Source code should contain function body statements") +} diff --git a/third_party/mcpgen/internal/codegen/templates/resolver.gotpl b/third_party/mcpgen/internal/codegen/templates/resolver.gotpl new file mode 100644 index 000000000..66f3332ec --- /dev/null +++ b/third_party/mcpgen/internal/codegen/templates/resolver.gotpl @@ -0,0 +1,58 @@ +package {{.Package}} + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by mcpgen. DO NOT EDIT. + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + {{- if .Imports}} + {{- range .Imports}} + {{- if .Alias}} + {{.Alias}} "{{.Path}}" + {{- else}} + "{{.Path}}" + {{- end}} + {{- end}} + {{- end}} +) + +{{- range .Tools}} + +{{- if .HasInputType}} +func (r *{{$.ResolverType}}) {{.HandlerName}}Tool(ctx context.Context, req *mcp.CallToolRequest, input *{{.InputType}}) (*mcp.CallToolResult, {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, error) { + return nil, {{if .HasOutputType}}{{.OutputType}}{}{{else}}nil{{end}}, fmt.Errorf("{{.Name}} not implemented") +} +{{- else}} +func (r *{{$.ResolverType}}) {{.HandlerName}}Tool(ctx context.Context, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, error) { + return nil, {{if .HasOutputType}}{{.OutputType}}{}{{else}}nil{{end}}, fmt.Errorf("{{.Name}} not implemented") +} +{{- end}} +{{- end}} + +{{- if .HasResources}} +{{- range .Resources}} + +func (r *{{$.ResolverType}}) {{.HandlerName}}Resource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return nil, fmt.Errorf("{{.Name}} not implemented") +} +{{- end}} +{{- end}} + +{{- if .HasPrompts}} +{{- range .Prompts}} + +{{- if .HasArgsType}} +func (r *{{$.ResolverType}}) {{.HandlerName}}Prompt(ctx context.Context, req *mcp.GetPromptRequest, args {{.ArgsType}}) (*mcp.GetPromptResult, error) { + return nil, fmt.Errorf("{{.Name}} not implemented") +} +{{- else}} +func (r *{{$.ResolverType}}) {{.HandlerName}}Prompt(ctx context.Context, req *mcp.GetPromptRequest, args map[string]string) (*mcp.GetPromptResult, error) { + return nil, fmt.Errorf("{{.Name}} not implemented") +} +{{- end}} +{{- end}} +{{- end}} diff --git a/third_party/mcpgen/internal/codegen/templates/resolver_struct.gotpl b/third_party/mcpgen/internal/codegen/templates/resolver_struct.gotpl new file mode 100644 index 000000000..f07f63adf --- /dev/null +++ b/third_party/mcpgen/internal/codegen/templates/resolver_struct.gotpl @@ -0,0 +1,22 @@ +package {{.Package}} + +// This file will NOT be regenerated automatically. +// +// It serves as a dependency injection container for your resolvers. +// Add any dependencies you need here (database connections, API clients, etc.) +// and they'll be available to all your tool, prompt, and resource resolvers. + +// {{.ResolverType}} is the root resolver that holds dependencies for all MCP handlers +type {{.ResolverType}} struct { + // Add your dependencies here, for example: + // DB *sql.DB + // Cache *redis.Client + // APIClient *http.Client +} + +// New{{.ResolverType}} creates a new resolver instance +func New{{.ResolverType}}() *{{.ResolverType}} { + return &{{.ResolverType}}{ + // Initialize your dependencies here + } +} diff --git a/third_party/mcpgen/internal/codegen/templates/server.gotpl b/third_party/mcpgen/internal/codegen/templates/server.gotpl new file mode 100644 index 000000000..27afcb6f1 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/templates/server.gotpl @@ -0,0 +1,175 @@ +// Code generated by mcpgen. DO NOT EDIT. + +package {{.Package}} + +import ( + "context" + "github.com/modelcontextprotocol/go-sdk/mcp" + {{- if .Imports}} + {{- range .Imports}} + {{- if .Alias}} + {{.Alias}} "{{.Path}}" + {{- else}} + "{{.Path}}" + {{- end}} + {{- end}} + {{- end}} + mcputil "go.probo.inc/mcpgen/mcp" +) + +// ResolverInterface defines the interface that must be implemented by the parent resolver +type ResolverInterface interface { + {{- range .Tools}} + {{.HandlerName}}Tool(ctx context.Context, req *mcp.CallToolRequest{{if .HasInputType}}, input *{{.InputType}}{{else}}, args map[string]any{{end}}) (*mcp.CallToolResult, {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, error) + {{- end}} + {{- if .HasResources}} + {{- range .Resources}} + {{.HandlerName}}Resource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) + {{- end}} + {{- end}} + {{- if .HasPrompts}} + {{- range .Prompts}} + {{.HandlerName}}Prompt(ctx context.Context, req *mcp.GetPromptRequest{{if .HasArgsType}}, args {{.ArgsType}}{{else}}, args map[string]string{{end}}) (*mcp.GetPromptResult, error) + {{- end}} + {{- end}} +} + +// New creates a new MCP server instance with all handlers registered. +// Returns a fully configured *mcp.Server ready to be used with any transport. +func New(resolver ResolverInterface, opts ...mcputil.Option) *mcp.Server { + o := mcputil.ApplyOptions(opts) + + server := mcp.NewServer( + &mcp.Implementation{ + Name: "{{.ServerName}}", + Version: "{{.ServerVersion}}", + }, + nil, + ) + + registerToolHandlers(server, resolver, &o) + {{- if .HasResources}} + registerResourceHandlers(server, resolver) + {{- end}} + {{- if .HasPrompts}} + registerPromptHandlers(server, resolver) + {{- end}} + + return server +} + +func registerToolHandlers(server *mcp.Server, resolver ResolverInterface, opts *mcputil.Options) { + {{- range .Tools}} + {{- $hasAnnotations := or .HasHints .Title}} + mcp.AddTool( + server, + &mcp.Tool{ + Name: "{{.Name}}", + {{- if .Title}} + Title: "{{.Title}}", + {{- end}} + Description: "{{.Description}}", + {{- if .HasInputType}} + InputSchema: {{.InputSchemaVar}}, + {{- end}} + {{- if .HasOutputType}} + OutputSchema: {{.OutputSchemaVar}}, + {{- end}} + {{- if $hasAnnotations}} + Annotations: &mcp.ToolAnnotations{ + {{- if .Title}} + Title: "{{.Title}}", + {{- end}} + {{- if .Readonly}} + ReadOnlyHint: true, + {{- else if .HasHints}} + ReadOnlyHint: false, + DestructiveHint: boolPtr({{if .Destructive}}true{{else}}false{{end}}), + {{- end}} + {{- if .Idempotent}} + IdempotentHint: true, + {{- end}} + {{- if .OpenWorld}} + OpenWorldHint: boolPtr(true), + {{- end}} + }, + {{- end}} + }, + func(ctx context.Context, req *mcp.CallToolRequest, input {{if .HasInputType}}*{{.InputType}}{{else}}map[string]any{{end}}) (result *mcp.CallToolResult, output {{if .HasOutputType}}{{.OutputType}}{{else}}map[string]any{{end}}, err error) { + defer func() { + if r := recover(); r != nil { + err = opts.RecoverFunc(ctx, r) + } + }() + return resolver.{{.HandlerName}}Tool(ctx, req, input) + }, + ) + + {{- end}} +} + +func boolPtr(b bool) *bool { + return &b +} + +{{- if .HasResources}} + +func registerResourceHandlers(server *mcp.Server, resolver ResolverInterface) { + {{- range .Resources}} + {{- if .URI}} + server.AddResource( + &mcp.Resource{ + URI: "{{.URI}}", + Name: "{{.Name}}", + Description: "{{.Description}}", + {{- if .MimeType}} + MIMEType: "{{.MimeType}}", + {{- end}} + }, + resolver.{{.HandlerName}}Resource, + ) + + {{- else if .URITemplate}} + server.AddResourceTemplate( + &mcp.ResourceTemplate{ + URITemplate: "{{.URITemplate}}", + Name: "{{.Name}}", + Description: "{{.Description}}", + {{- if .MimeType}} + MIMEType: "{{.MimeType}}", + {{- end}} + }, + resolver.{{.HandlerName}}Resource, + ) + + {{- end}} + {{- end}} +} +{{- end}} + +{{- if .HasPrompts}} + +func registerPromptHandlers(server *mcp.Server, resolver ResolverInterface) { + {{- range .Prompts}} + mcputil.AddPrompt( + server, + &mcp.Prompt{ + Name: "{{.Name}}", + Description: "{{.Description}}", + {{- if .Arguments}} + Arguments: []*mcp.PromptArgument{ + {{- range .Arguments}} + { + Name: "{{.Name}}", + Description: "{{.Description}}", + Required: {{.Required}}, + }, + {{- end}} + }, + {{- end}} + }, + resolver.{{.HandlerName}}Prompt, + ) + {{- end}} +} +{{- end}} diff --git a/third_party/mcpgen/internal/codegen/testdata/all_primitives.yaml b/third_party/mcpgen/internal/codegen/testdata/all_primitives.yaml new file mode 100644 index 000000000..3f95f95b0 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/testdata/all_primitives.yaml @@ -0,0 +1,222 @@ +info: + title: all-primitives-test + version: 1.0.0 + description: Test all MCP primitives and JSON Schema features + +components: + schemas: + # All JSON Schema types + StringSchema: + type: string + description: A string + + NumberSchema: + type: number + description: A number + + IntegerSchema: + type: integer + description: An integer + + BooleanSchema: + type: boolean + description: A boolean + + ArraySchema: + type: array + items: + type: string + description: An array of strings + + ObjectSchema: + type: object + properties: + name: + type: string + value: + type: number + required: [name] + + # Enum types + Color: + type: string + enum: [red, green, blue, yellow] + description: A color + + # Nested objects + Address: + type: object + properties: + street: + type: string + city: + type: string + zipCode: + type: string + country: + type: string + required: [city, country] + + Person: + type: object + properties: + name: + type: string + age: + type: integer + email: + type: string + format: email + address: + $ref: "#/components/schemas/Address" + favoriteColor: + $ref: "#/components/schemas/Color" + tags: + type: array + items: + type: string + metadata: + type: object + additionalProperties: true + required: [name] + + # Nullable fields with anyOf + NullableFields: + type: object + properties: + nullableString: + anyOf: + - type: string + - type: "null" + nullableNumber: + anyOf: + - type: number + - type: "null" + nullableObject: + anyOf: + - $ref: "#/components/schemas/Address" + - type: "null" + + # Complex nested structure + Organization: + type: object + properties: + id: + type: string + name: + type: string + members: + type: array + items: + $ref: "#/components/schemas/Person" + headquarters: + $ref: "#/components/schemas/Address" + founded: + type: string + format: date + required: [id, name] + +tools: + # Tool with inline schema + - name: simple_tool + description: A simple tool with inline schema + inputSchema: + type: object + properties: + message: + type: string + count: + type: integer + required: [message] + + # Tool with ref schema + - name: create_person + description: Create a person + inputSchema: + $ref: "#/components/schemas/Person" + + # Tool with complex schema + - name: create_organization + description: Create an organization + inputSchema: + $ref: "#/components/schemas/Organization" + + # Tool with enum + - name: set_color + description: Set a color + inputSchema: + type: object + properties: + color: + $ref: "#/components/schemas/Color" + required: [color] + + # Tool with nullable fields + - name: update_fields + description: Update optional fields + inputSchema: + $ref: "#/components/schemas/NullableFields" + +resources: + # Static resource + - uri: "docs://readme" + name: README + description: The README document + mimeType: text/markdown + readonly: true + + # Resource with simple template + - uriTemplate: "person://{id}" + name: Person Resource + description: Get a person by ID + mimeType: application/json + readonly: true + schema: + $ref: "#/components/schemas/Person" + + # Resource with multiple parameters + - uriTemplate: "org://{orgId}/member/{memberId}" + name: Organization Member + description: Get a member of an organization + mimeType: application/json + readonly: true + schema: + $ref: "#/components/schemas/Person" + + # Resource with nested schema + - uriTemplate: "org://{id}" + name: Organization Resource + description: Get an organization by ID + mimeType: application/json + schema: + $ref: "#/components/schemas/Organization" + +prompts: + # Prompt without arguments + - name: help + description: Get general help + + # Prompt with optional arguments + - name: person_info + description: Get information about a person + arguments: + - name: personId + description: The person ID + required: true + - name: includeAddress + description: Include address in the response + required: false + - name: format + description: Output format + required: false + + # Prompt with all required arguments + - name: compare_people + description: Compare two people + arguments: + - name: person1Id + description: First person ID + required: true + - name: person2Id + description: Second person ID + required: true diff --git a/third_party/mcpgen/internal/codegen/testdata/config_based_types.golden b/third_party/mcpgen/internal/codegen/testdata/config_based_types.golden new file mode 100644 index 000000000..ef3e5f38b --- /dev/null +++ b/third_party/mcpgen/internal/codegen/testdata/config_based_types.golden @@ -0,0 +1,31 @@ +// Code generated by mcpgen. DO NOT EDIT. + +package test + +import ( + "github.com/google/uuid" + "github.com/myapp/models" + mcputil "go.probo.inc/mcpgen/mcp" + "time" +) + +// Tool input schemas +var ( + CreateEventToolInputSchema = mcputil.MustUnmarshalSchema(`{"type":"object","required":["id","name","createdAt"],"properties":{"createdAt":{"$ref":"#/components/schemas/Timestamp"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"owner":{"$ref":"#/components/schemas/User"}}}`) +) + +// Event represents the schema +type Event struct { + Name string `json:"name"` + Owner models.User `json:"owner,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ID uuid.UUID `json:"id"` +} + +// CreateEventInput represents the schema +type CreateEventInput struct { + Name string `json:"name"` + Owner models.User `json:"owner,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ID uuid.UUID `json:"id"` +} diff --git a/third_party/mcpgen/internal/codegen/testdata/config_based_types.yaml b/third_party/mcpgen/internal/codegen/testdata/config_based_types.yaml new file mode 100644 index 000000000..b92a37d07 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/testdata/config_based_types.yaml @@ -0,0 +1,45 @@ +info: + title: config-based-test + version: 1.0.0 + description: Test config-based custom type mapping + +components: + schemas: + # These will be mapped via config, not go.probo.inc/mcpgen/type + Timestamp: + type: string + format: date-time + + UUID: + type: string + format: uuid + + User: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + format: email + required: [id, name] + + Event: + type: object + properties: + id: + $ref: "#/components/schemas/UUID" + name: + type: string + owner: + $ref: "#/components/schemas/User" + createdAt: + $ref: "#/components/schemas/Timestamp" + required: [id, name, createdAt] + +tools: + - name: create_event + inputSchema: + $ref: "#/components/schemas/Event" diff --git a/third_party/mcpgen/internal/codegen/testdata/custom_types.yaml b/third_party/mcpgen/internal/codegen/testdata/custom_types.yaml new file mode 100644 index 000000000..a0f112bce --- /dev/null +++ b/third_party/mcpgen/internal/codegen/testdata/custom_types.yaml @@ -0,0 +1,185 @@ +info: + title: custom-types-test + version: 1.0.0 + description: Test all custom type mapping scenarios + +components: + schemas: + # Standard library types with go.probo.inc/mcpgen/type + Timestamp: + type: string + format: date-time + description: A timestamp + go.probo.inc/mcpgen/type: time.Time + + Duration: + type: string + description: A duration + go.probo.inc/mcpgen/type: time.Duration + + # External package types + UUID: + type: string + format: uuid + description: A UUID + go.probo.inc/mcpgen/type: github.com/google/uuid.UUID + + Decimal: + type: string + description: A decimal number + go.probo.inc/mcpgen/type: github.com/shopspring/decimal.Decimal + + # JSON raw message + Metadata: + type: object + description: Raw JSON metadata + go.probo.inc/mcpgen/type: json.RawMessage + + # Regular enum (should be generated) + Status: + type: string + enum: [pending, in_progress, completed, cancelled] + description: Task status + + # Regular object (should be generated) + Task: + type: object + description: A task + properties: + id: + $ref: "#/components/schemas/UUID" + title: + type: string + description: Task title + status: + $ref: "#/components/schemas/Status" + createdAt: + $ref: "#/components/schemas/Timestamp" + updatedAt: + anyOf: + - $ref: "#/components/schemas/Timestamp" + - type: "null" + duration: + $ref: "#/components/schemas/Duration" + metadata: + $ref: "#/components/schemas/Metadata" + tags: + type: array + items: + type: string + description: Task tags + priority: + type: integer + description: Priority level + required: [id, title, status, createdAt] + + # Object with all nullable custom types + OptionalFields: + type: object + properties: + optionalTimestamp: + anyOf: + - $ref: "#/components/schemas/Timestamp" + - type: "null" + optionalUUID: + anyOf: + - $ref: "#/components/schemas/UUID" + - type: "null" + optionalDecimal: + anyOf: + - $ref: "#/components/schemas/Decimal" + - type: "null" + + # Nested objects + Project: + type: object + properties: + id: + $ref: "#/components/schemas/UUID" + name: + type: string + tasks: + type: array + items: + $ref: "#/components/schemas/Task" + createdAt: + $ref: "#/components/schemas/Timestamp" + required: [id, name, createdAt] + + # Update input with omittable fields + UpdateTaskInput: + type: object + description: Input for partial task update + properties: + id: + $ref: "#/components/schemas/UUID" + description: Task ID to update + title: + anyOf: + - type: string + - type: "null" + description: New title (omit to keep unchanged, null to clear) + go.probo.inc/mcpgen/omittable: true + status: + anyOf: + - $ref: "#/components/schemas/Status" + - type: "null" + description: New status (omit to keep unchanged) + go.probo.inc/mcpgen/omittable: true + priority: + anyOf: + - type: integer + - type: "null" + description: New priority (omit to keep unchanged, null to clear) + go.probo.inc/mcpgen/omittable: true + tags: + anyOf: + - type: array + items: + type: string + - type: "null" + description: New tags (omit to keep unchanged, null to clear) + go.probo.inc/mcpgen/omittable: true + required: [id] + +tools: + - name: create_task + description: Create a new task + inputSchema: + $ref: "#/components/schemas/Task" + + - name: update_task + description: Update task fields (partial update with omittable fields) + inputSchema: + $ref: "#/components/schemas/UpdateTaskInput" + + - name: create_project + description: Create a new project + inputSchema: + $ref: "#/components/schemas/Project" + +resources: + - uriTemplate: "task://{id}" + name: Task Resource + description: Get a task by ID + mimeType: application/json + schema: + $ref: "#/components/schemas/Task" + + - uriTemplate: "project://{id}" + name: Project Resource + description: Get a project by ID + mimeType: application/json + schema: + $ref: "#/components/schemas/Project" + +prompts: + - name: task_summary + description: Generate a task summary + arguments: + - name: taskId + description: The task ID + required: true + - name: includeMetadata + description: Include metadata in summary + required: false diff --git a/third_party/mcpgen/internal/codegen/types.go b/third_party/mcpgen/internal/codegen/types.go new file mode 100644 index 000000000..af0d58425 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/types.go @@ -0,0 +1,652 @@ +package codegen + +import ( + "fmt" + "go/format" + "sort" + "strings" + + "go.probo.inc/mcpgen/internal/schema" +) + +type CustomTypeMapping struct { + GoType string + ImportPath string + IsPointer bool +} + +type TypeGenerator struct { + schemas map[string]*schema.Schema + types map[string]string + enums map[string]string + imports map[string]bool + schemaVars map[string]string + customMappings map[string]*CustomTypeMapping +} + +func NewTypeGenerator() *TypeGenerator { + return &TypeGenerator{ + schemas: make(map[string]*schema.Schema), + types: make(map[string]string), + enums: make(map[string]string), + imports: make(map[string]bool), + schemaVars: make(map[string]string), + customMappings: make(map[string]*CustomTypeMapping), + } +} + +func (g *TypeGenerator) AddCustomMapping(schemaName string, mapping *CustomTypeMapping) { + g.customMappings[schemaName] = mapping +} + +func (g *TypeGenerator) AddSchema(name string, s *schema.Schema) { + g.schemas[name] = s +} + +func (g *TypeGenerator) AddSchemaVar(name string, schemaJSON string) { + g.schemaVars[name] = schemaJSON + g.imports["go.probo.inc/mcpgen/mcp"] = true +} + +func (g *TypeGenerator) Generate(packageName string) ([]byte, error) { + var buf strings.Builder + + buf.WriteString("// Code generated by mcpgen. DO NOT EDIT.\n\n") + buf.WriteString(fmt.Sprintf("package %s\n\n", packageName)) + + // Sort schema names for deterministic output + schemaNames := make([]string, 0, len(g.schemas)) + for name := range g.schemas { + schemaNames = append(schemaNames, name) + } + sort.Strings(schemaNames) + + for _, name := range schemaNames { + s := g.schemas[name] + typeName := toGoTypeName(name) + + if _, hasCustomMapping := g.customMappings[name]; hasCustomMapping { + continue + } + + typeCode, err := g.generateType(typeName, s, 0) + if err != nil { + return nil, fmt.Errorf("failed to generate type for %s: %w", name, err) + } + + if typeCode != "" && g.types[typeName] == "" { + g.types[typeName] = typeCode + } + } + + if len(g.imports) > 0 { + buf.WriteString("import (\n") + // Sort imports for deterministic output + imports := make([]string, 0, len(g.imports)) + for imp := range g.imports { + imports = append(imports, imp) + } + sort.Strings(imports) + for _, imp := range imports { + buf.WriteString(fmt.Sprintf("\t\"%s\"\n", imp)) + } + buf.WriteString(")\n\n") + } + + if len(g.schemaVars) > 0 { + buf.WriteString("// Tool input schemas\n") + buf.WriteString("var (\n") + // Sort schema var names for deterministic output + varNames := make([]string, 0, len(g.schemaVars)) + for varName := range g.schemaVars { + varNames = append(varNames, varName) + } + sort.Strings(varNames) + for _, varName := range varNames { + schemaJSON := g.schemaVars[varName] + buf.WriteString(fmt.Sprintf("\t%s = mcp.MustUnmarshalSchema(`%s`)\n", varName, schemaJSON)) + } + buf.WriteString(")\n\n") + } + + // Sort enum names for deterministic output + enumNames := make([]string, 0, len(g.enums)) + for enumName := range g.enums { + enumNames = append(enumNames, enumName) + } + sort.Strings(enumNames) + for _, enumName := range enumNames { + enumCode := g.enums[enumName] + buf.WriteString(enumCode) + buf.WriteString("\n\n") + } + + written := make(map[string]bool) + + // Sort schema names for deterministic output (second pass) + for _, name := range schemaNames { + typeName := toGoTypeName(name) + if typeCode := g.types[typeName]; typeCode != "" { + buf.WriteString(typeCode) + buf.WriteString("\n\n") + written[typeName] = true + } + } + + // Sort type names for deterministic output + typeNames := make([]string, 0, len(g.types)) + for typeName := range g.types { + typeNames = append(typeNames, typeName) + } + sort.Strings(typeNames) + for _, typeName := range typeNames { + typeCode := g.types[typeName] + if !written[typeName] && typeCode != "" { + buf.WriteString(typeCode) + buf.WriteString("\n\n") + } + } + + formatted, err := format.Source([]byte(buf.String())) + if err != nil { + return nil, fmt.Errorf("failed to format generated code: %w\n%s", err, buf.String()) + } + + return formatted, nil +} + +func (g *TypeGenerator) generateType(name string, s *schema.Schema, depth int) (string, error) { + schemaType := schema.GetType(s) + + if schemaType == "" && s.Properties != nil && len(s.Properties) > 0 { + return g.generateStruct(name, s, depth) + } + + if schemaType == "" && s.Properties == nil { + return "", fmt.Errorf("unsupported schema type: %q (no type and no properties for %s)", schemaType, name) + } + + if len(s.Enum) > 0 { + return g.generateEnum(name, s) + } + + switch schemaType { + case "object": + return g.generateStruct(name, s, depth) + case "array": + return g.generateArrayType(name, s, depth) + case "string": + if depth == 0 { + return g.generatePrimitiveTypeAlias(name, s, "string") + } + return "", nil + case "number": + if depth == 0 { + return g.generatePrimitiveTypeAlias(name, s, "float64") + } + return "", nil + case "integer": + if depth == 0 { + return g.generatePrimitiveTypeAlias(name, s, "int") + } + return "", nil + case "boolean": + if depth == 0 { + return g.generatePrimitiveTypeAlias(name, s, "bool") + } + return "", nil + default: + if len(s.Properties) > 0 { + return g.generateStruct(name, s, depth) + } + return "", fmt.Errorf("unsupported schema type: %s", schemaType) + } +} + +func (g *TypeGenerator) generateStruct(name string, s *schema.Schema, depth int) (string, error) { + var buf strings.Builder + + if s.Description != "" { + buf.WriteString(formatComment(s.Description, "")) + } else if s.Title != "" { + buf.WriteString(formatComment(s.Title, "")) + } else { + buf.WriteString(fmt.Sprintf("// %s represents the schema\n", name)) + } + + buf.WriteString(fmt.Sprintf("type %s struct {\n", name)) + + // Sort property names for deterministic output + propNames := make([]string, 0, len(s.Properties)) + for propName := range s.Properties { + propNames = append(propNames, propName) + } + sort.Strings(propNames) + + for _, propName := range propNames { + propSchema := s.Properties[propName] + fieldName := toGoFieldName(propName) + hint := name + fieldName + + isRequired := schema.IsRequired(s, propName) + isOmittable := schema.IsOmittable(propSchema) + + // Validate that omittable is only used on nullable fields + if isOmittable { + isNullable, _ := isNullableType(propSchema) + if !isNullable { + return "", fmt.Errorf("field %s.%s has omittable annotation but is not nullable (omittable only works with nullable fields)", name, propName) + } + } + + fieldType, err := g.goType(propSchema, hint) + if err != nil { + return "", fmt.Errorf("failed to generate field %s: %w", propName, err) + } + + if isOmittable { + fieldType = fmt.Sprintf("mcp.Omittable[%s]", fieldType) + g.imports["go.probo.inc/mcpgen/mcp"] = true + } else if !isRequired && !isPointerType(fieldType) { + fieldType = "*" + fieldType + } + + if propSchema.Description != "" { + buf.WriteString(formatComment(propSchema.Description, "\t")) + } + + buf.WriteString(fmt.Sprintf("\t%s %s", fieldName, fieldType)) + + jsonTag := propName + if !isRequired { + jsonTag += ",omitempty" + } + buf.WriteString(fmt.Sprintf(" `json:\"%s\"`", jsonTag)) + + buf.WriteString("\n") + } + + buf.WriteString("}") + + return buf.String(), nil +} + +// isPointerType checks if the given type string is already a pointer or slice type +func isPointerType(t string) bool { + return len(t) > 0 && (t[0] == '*' || t[0] == '[') +} + +func isNullableType(s *schema.Schema) (bool, *schema.Schema) { + if len(s.AnyOf) == 2 { + var nullIndex = -1 + var typeIndex = -1 + + for i, subSchema := range s.AnyOf { + subType := schema.GetType(subSchema) + if subType == "null" { + nullIndex = i + } else if subType != "" || subSchema.Properties != nil || subSchema.Ref != "" { + typeIndex = i + } + } + + if nullIndex >= 0 && typeIndex >= 0 { + return true, s.AnyOf[typeIndex] + } + } + + if len(s.Types) > 0 { + hasNull := false + var otherType string + for _, t := range s.Types { + if t == "null" { + hasNull = true + } else if otherType == "" { + otherType = t + } + } + + if hasNull && otherType != "" && len(s.Types) == 2 { + syntheticSchema := &schema.Schema{ + Type: otherType, + Format: s.Format, + } + return true, syntheticSchema + } + } + + return false, nil +} + +func (g *TypeGenerator) generateArrayType(name string, s *schema.Schema, depth int) (string, error) { + if s.Items == nil { + if depth == 0 { + return g.generatePrimitiveTypeAlias(name, s, "[]any") + } + return "[]any", nil + } + + itemType, err := g.goType(s.Items, name+"Item") + if err != nil { + return "", err + } + + arrayType := fmt.Sprintf("[]%s", itemType) + + if depth == 0 { + return g.generatePrimitiveTypeAlias(name, s, arrayType) + } + + return arrayType, nil +} + +func (g *TypeGenerator) goType(s *schema.Schema, hint string) (string, error) { + if s.Ref != "" { + const prefix = "#/components/schemas/" + if len(s.Ref) > len(prefix) && s.Ref[:len(prefix)] == prefix { + schemaName := s.Ref[len(prefix):] + + if customMapping, ok := g.customMappings[schemaName]; ok { + if customMapping.ImportPath != "" { + g.imports[customMapping.ImportPath] = true + } + if customMapping.IsPointer { + return "*" + customMapping.GoType, nil + } + return customMapping.GoType, nil + } + + return "*" + toGoTypeName(schemaName), nil + } + } + + if nullable, baseType := isNullableType(s); nullable { + goType, err := g.goType(baseType, hint) + if err != nil { + return "", err + } + if len(goType) > 0 && goType[0] == '*' { + return goType, nil + } + return "*" + goType, nil + } + + schemaType := schema.GetType(s) + + switch schemaType { + case "string": + if len(s.Enum) > 0 { + enumTypeName := toGoTypeName(hint) + if g.enums[enumTypeName] == "" { + enumCode, err := g.generateEnum(enumTypeName, s) + if err != nil { + return "", err + } + g.enums[enumTypeName] = enumCode + } + return enumTypeName, nil + } + return g.goStringType(s), nil + case "number": + return "float64", nil + case "integer": + return "int", nil + case "boolean": + return "bool", nil + case "array": + if s.Items == nil { + return "[]any", nil + } + itemType, err := g.goType(s.Items, hint+"Item") + if err != nil { + return "", err + } + return fmt.Sprintf("[]%s", itemType), nil + case "object": + if s.Title != "" { + typeName := toGoTypeName(s.Title) + if g.types[typeName] == "" { + typeCode, err := g.generateStruct(typeName, s, 0) + if err != nil { + return "", err + } + g.types[typeName] = typeCode + } + return typeName, nil + } + if len(s.Properties) > 0 { + typeName := hint + if g.types[typeName] == "" { + typeCode, err := g.generateStruct(typeName, s, 0) + if err != nil { + return "", err + } + g.types[typeName] = typeCode + } + return typeName, nil + } + return "map[string]any", nil + case "null": + return "any", nil + default: + if len(s.Properties) > 0 { + typeName := toGoTypeName(hint) + if g.types[typeName] == "" { + typeCode, err := g.generateStruct(typeName, s, 0) + if err != nil { + return "", err + } + g.types[typeName] = typeCode + } + return typeName, nil + } + return "any", nil + } +} + +func (g *TypeGenerator) generatePrimitiveTypeAlias(name string, s *schema.Schema, goType string) (string, error) { + var buf strings.Builder + + if s.Description != "" { + buf.WriteString(formatComment(s.Description, "")) + } else { + buf.WriteString(fmt.Sprintf("// %s represents a %s schema\n", name, goType)) + } + + buf.WriteString(fmt.Sprintf("type %s %s", name, goType)) + return buf.String(), nil +} + +func (g *TypeGenerator) generateEnum(enumTypeName string, s *schema.Schema) (string, error) { + if len(s.Enum) == 0 { + return "", fmt.Errorf("schema has no enum values") + } + + var buf strings.Builder + + if s.Description != "" { + buf.WriteString(formatComment(s.Description, "")) + } else { + buf.WriteString(fmt.Sprintf("// %s represents an enumeration\n", enumTypeName)) + } + + buf.WriteString(fmt.Sprintf("type %s string\n\n", enumTypeName)) + + buf.WriteString("const (\n") + var enumValues []string + for i, enumValue := range s.Enum { + strValue := fmt.Sprintf("%v", enumValue) + enumValues = append(enumValues, strValue) + constName := toEnumConstName(enumTypeName, strValue) + + if i == 0 { + buf.WriteString(fmt.Sprintf("\t%s %s = %q\n", constName, enumTypeName, strValue)) + } else { + buf.WriteString(fmt.Sprintf("\t%s %s = %q\n", constName, enumTypeName, strValue)) + } + } + buf.WriteString(")\n\n") + + // Generate validation method + buf.WriteString(fmt.Sprintf("// IsValid returns true if the %s value is valid\n", enumTypeName)) + buf.WriteString(fmt.Sprintf("func (e %s) IsValid() bool {\n", enumTypeName)) + buf.WriteString("\tswitch e {\n") + for _, strValue := range enumValues { + constName := toEnumConstName(enumTypeName, strValue) + buf.WriteString(fmt.Sprintf("\tcase %s:\n\t\treturn true\n", constName)) + } + buf.WriteString("\t}\n") + buf.WriteString("\treturn false\n") + buf.WriteString("}\n\n") + + // Generate UnmarshalJSON method + buf.WriteString("// UnmarshalJSON implements json.Unmarshaler\n") + buf.WriteString(fmt.Sprintf("func (e *%s) UnmarshalJSON(data []byte) error {\n", enumTypeName)) + buf.WriteString("\tvar s string\n") + buf.WriteString("\tif err := json.Unmarshal(data, &s); err != nil {\n") + buf.WriteString("\t\treturn err\n") + buf.WriteString("\t}\n") + buf.WriteString(fmt.Sprintf("\t*e = %s(s)\n", enumTypeName)) + buf.WriteString("\tif !e.IsValid() {\n") + buf.WriteString(fmt.Sprintf("\t\treturn fmt.Errorf(\"invalid %s value: %%q\", s)\n", enumTypeName)) + buf.WriteString("\t}\n") + buf.WriteString("\treturn nil\n") + buf.WriteString("}\n\n") + + // Generate MarshalJSON method + buf.WriteString("// MarshalJSON implements json.Marshaler\n") + buf.WriteString(fmt.Sprintf("func (e %s) MarshalJSON() ([]byte, error) {\n", enumTypeName)) + buf.WriteString("\tif !e.IsValid() {\n") + buf.WriteString(fmt.Sprintf("\t\treturn nil, fmt.Errorf(\"invalid %s value: %%q\", string(e))\n", enumTypeName)) + buf.WriteString("\t}\n") + buf.WriteString("\treturn json.Marshal(string(e))\n") + buf.WriteString("}") + + g.imports["encoding/json"] = true + g.imports["fmt"] = true + + return buf.String(), nil +} + +func (g *TypeGenerator) goStringType(s *schema.Schema) string { + switch s.Format { + case "date-time": + g.imports["time"] = true + return "time.Time" + case "date", "time", "email", "hostname", "ipv4", "ipv6", "uri", "uuid": + return "string" + default: + return "string" + } +} + +func toGoTypeName(name string) string { + name = strings.TrimSuffix(name, ".json") + name = strings.TrimSuffix(name, "_input") + name = strings.TrimSuffix(name, "_output") + name = strings.TrimSuffix(name, "_schema") + + parts := strings.FieldsFunc(name, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' || r == '.' + }) + + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + + return strings.Join(parts, "") +} + +var goAcronyms = map[string]bool{ + "acl": true, + "api": true, + "ascii": true, + "cpu": true, + "css": true, + "dns": true, + "eof": true, + "guid": true, + "html": true, + "http": true, + "https": true, + "id": true, + "ip": true, + "json": true, + "jwt": true, + "lhs": true, + "qps": true, + "ram": true, + "rhs": true, + "rpc": true, + "sla": true, + "smtp": true, + "sql": true, + "ssh": true, + "tcp": true, + "tls": true, + "ttl": true, + "udp": true, + "ui": true, + "uid": true, + "uri": true, + "url": true, + "utf": true, + "uuid": true, + "vm": true, + "xml": true, +} + +var goSpecialCase = map[string]string{ + "oauth": "OAuth", +} + +func toGoFieldName(name string) string { + parts := strings.FieldsFunc(name, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' + }) + + for i, part := range parts { + if len(part) > 0 { + lowerPart := strings.ToLower(part) + if specialCase, ok := goSpecialCase[lowerPart]; ok { + parts[i] = specialCase + } else if goAcronyms[lowerPart] { + parts[i] = strings.ToUpper(part) + } else { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + } + + return strings.Join(parts, "") +} + +func formatComment(text, prefix string) string { + lines := strings.Split(strings.TrimSpace(text), "\n") + var result strings.Builder + + for _, line := range lines { + result.WriteString(fmt.Sprintf("%s// %s\n", prefix, strings.TrimSpace(line))) + } + + return result.String() +} + +func toEnumConstName(enumTypeName, value string) string { + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' || r == '.' + }) + + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + + constName := strings.Join(parts, "") + baseName := strings.TrimSuffix(enumTypeName, "Type") + + return baseName + constName +} diff --git a/third_party/mcpgen/internal/codegen/types_test.go b/third_party/mcpgen/internal/codegen/types_test.go new file mode 100644 index 000000000..33aeca3c9 --- /dev/null +++ b/third_party/mcpgen/internal/codegen/types_test.go @@ -0,0 +1,1048 @@ +package codegen + +import ( + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/mcpgen/internal/config" +) + +func TestParseTypeMapping(t *testing.T) { + tests := []struct { + name string + input string + wantGoType string + wantImportPath string + }{ + { + name: "built-in type", + input: "string", + wantGoType: "string", + wantImportPath: "", + }, + { + name: "standard library type", + input: "time.Time", + wantGoType: "time.Time", + wantImportPath: "time", + }, + { + name: "external package with full path", + input: "github.com/google/uuid.UUID", + wantGoType: "uuid.UUID", + wantImportPath: "github.com/google/uuid", + }, + { + name: "external package with nested path", + input: "github.com/shopspring/decimal.Decimal", + wantGoType: "decimal.Decimal", + wantImportPath: "github.com/shopspring/decimal", + }, + { + name: "json.RawMessage", + input: "json.RawMessage", + wantGoType: "json.RawMessage", + wantImportPath: "json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTypeMapping(tt.input) + assert.Equal(t, tt.wantGoType, got.GoType) + assert.Equal(t, tt.wantImportPath, got.ImportPath) + }) + } +} + +func TestExtractGoTypeAnnotation(t *testing.T) { + tests := []struct { + name string + schema *config.Schema + want string + }{ + { + name: "nil schema", + schema: nil, + want: "", + }, + { + name: "schema without extra", + schema: &config.Schema{ + Type: "string", + }, + want: "", + }, + { + name: "schema with go.probo.inc/mcpgen/type annotation", + schema: &config.Schema{ + Type: "string", + Extra: map[string]any{ + "go.probo.inc/mcpgen/type": "time.Time", + }, + }, + want: "time.Time", + }, + { + name: "schema with other annotations", + schema: &config.Schema{ + Type: "string", + Extra: map[string]any{ + "x-custom": "value", + }, + }, + want: "", + }, + { + name: "schema with non-string go.probo.inc/mcpgen/type", + schema: &config.Schema{ + Type: "string", + Extra: map[string]any{ + "go.probo.inc/mcpgen/type": 123, + }, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractGoTypeAnnotation(tt.schema) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTypeGeneratorCustomMappings(t *testing.T) { + tests := []struct { + name string + setupGen func(*TypeGenerator) + schema *config.Schema + hint string + wantType string + wantImports []string + wantErr bool + }{ + { + name: "custom mapping for ref", + setupGen: func(g *TypeGenerator) { + g.AddCustomMapping("Timestamp", &CustomTypeMapping{ + GoType: "time.Time", + ImportPath: "time", + }) + }, + schema: &config.Schema{ + Ref: "#/components/schemas/Timestamp", + }, + hint: "CreatedAt", + wantType: "time.Time", + wantImports: []string{"time"}, + wantErr: false, + }, + { + name: "custom mapping for UUID", + setupGen: func(g *TypeGenerator) { + g.AddCustomMapping("UUID", &CustomTypeMapping{ + GoType: "uuid.UUID", + ImportPath: "github.com/google/uuid", + }) + }, + schema: &config.Schema{ + Ref: "#/components/schemas/UUID", + }, + hint: "ID", + wantType: "uuid.UUID", + wantImports: []string{"github.com/google/uuid"}, + wantErr: false, + }, + { + name: "nullable custom type", + setupGen: func(g *TypeGenerator) { + g.AddCustomMapping("Timestamp", &CustomTypeMapping{ + GoType: "time.Time", + ImportPath: "time", + }) + }, + schema: &config.Schema{ + AnyOf: []*config.Schema{ + {Ref: "#/components/schemas/Timestamp"}, + {Type: "null"}, + }, + }, + hint: "UpdatedAt", + wantType: "*time.Time", + wantImports: []string{"time"}, + wantErr: false, + }, + { + name: "regular ref without custom mapping", + setupGen: func(g *TypeGenerator) { + // No custom mapping added + }, + schema: &config.Schema{ + Ref: "#/components/schemas/User", + }, + hint: "Owner", + wantType: "*User", + wantImports: []string{}, + wantErr: false, + }, + { + name: "string type", + setupGen: func(g *TypeGenerator) { + // No custom mapping needed + }, + schema: &config.Schema{ + Type: "string", + }, + hint: "Name", + wantType: "string", + wantImports: []string{}, + wantErr: false, + }, + { + name: "time.Time from format", + setupGen: func(g *TypeGenerator) { + // No custom mapping, should use format + }, + schema: &config.Schema{ + Type: "string", + Format: "date-time", + }, + hint: "CreatedAt", + wantType: "time.Time", + wantImports: []string{"time"}, + wantErr: false, + }, + { + name: "nullable time.Time from format with types array", + setupGen: func(g *TypeGenerator) { + // No custom mapping, should use format + }, + schema: &config.Schema{ + Types: []string{"string", "null"}, + Format: "date-time", + }, + hint: "ContractStartDate", + wantType: "*time.Time", + wantImports: []string{"time"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := NewTypeGenerator() + tt.setupGen(gen) + + gotType, err := gen.goType(tt.schema, tt.hint) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + assert.Equal(t, tt.wantType, gotType) + + for _, wantImport := range tt.wantImports { + assert.True(t, gen.imports[wantImport], "expected import %q not found", wantImport) + } + }) + } +} + +func TestTypeGeneratorSkipsCustomMappedSchemas(t *testing.T) { + gen := NewTypeGenerator() + + gen.AddCustomMapping("Timestamp", &CustomTypeMapping{ + GoType: "time.Time", + ImportPath: "time", + }) + + gen.AddSchema("Timestamp", &config.Schema{ + Type: "string", + Format: "date-time", + }) + + // Add a schema that references Timestamp (so the import gets added) + gen.AddSchema("Event", &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "name": {Type: "string"}, + "createdAt": { + Ref: "#/components/schemas/Timestamp", + }, + }, + }) + + gen.AddSchema("User", &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "name": {Type: "string"}, + }, + }) + + code, err := gen.Generate("test") + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + codeStr := string(code) + + if contains := containsTypeDefinition(codeStr, "type Timestamp"); contains { + t.Error("Generated code should not contain Timestamp type definition (it's custom mapped)") + } + + if !containsTypeDefinition(codeStr, "type User struct") { + t.Error("Generated code should contain User type definition") + } + + if !containsTypeDefinition(codeStr, "type Event struct") { + t.Error("Generated code should contain Event type definition") + } + + if !containsImport(codeStr, "time") { + t.Error("Generated code should import time package") + } +} + +func TestIsNullableType(t *testing.T) { + tests := []struct { + name string + schema *config.Schema + wantNullable bool + wantSchema *config.Schema + }{ + { + name: "anyOf with null and type", + schema: &config.Schema{ + AnyOf: []*config.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + wantNullable: true, + wantSchema: &config.Schema{Type: "string"}, + }, + { + name: "anyOf with null and ref", + schema: &config.Schema{ + AnyOf: []*config.Schema{ + {Ref: "#/components/schemas/User"}, + {Type: "null"}, + }, + }, + wantNullable: true, + wantSchema: &config.Schema{Ref: "#/components/schemas/User"}, + }, + { + name: "anyOf with multiple types (not nullable)", + schema: &config.Schema{ + AnyOf: []*config.Schema{ + {Type: "string"}, + {Type: "number"}, + }, + }, + wantNullable: false, + wantSchema: nil, + }, + { + name: "regular type (not nullable)", + schema: &config.Schema{ + Type: "string", + }, + wantNullable: false, + wantSchema: nil, + }, + { + name: "types array with null", + schema: &config.Schema{ + Types: []string{"string", "null"}, + }, + wantNullable: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotNullable, gotSchema := isNullableType(tt.schema) + if gotNullable != tt.wantNullable { + t.Errorf("isNullableType() nullable = %v, want %v", gotNullable, tt.wantNullable) + } + if tt.wantSchema != nil && gotSchema != nil { + if gotSchema.Type != tt.wantSchema.Type || gotSchema.Ref != tt.wantSchema.Ref { + t.Errorf("isNullableType() schema = %+v, want %+v", gotSchema, tt.wantSchema) + } + } + }) + } +} + +func TestEnumGeneration(t *testing.T) { + gen := NewTypeGenerator() + + enumSchema := &config.Schema{ + Type: "string", + Enum: []any{"pending", "in_progress", "completed"}, + Description: "Task status", + } + + code, err := gen.generateEnum("Status", enumSchema) + if err != nil { + t.Fatalf("generateEnum() error = %v", err) + } + + if !containsTypeDefinition(code, "type Status string") { + t.Error("Generated enum should contain type definition") + } + + expectedConstants := []string{"StatusPending", "StatusInProgress", "StatusCompleted"} + for _, constant := range expectedConstants { + if !containsString(code, constant) { + t.Errorf("Generated enum should contain constant %q", constant) + } + } + + if !containsString(code, "func (e Status) IsValid() bool") { + t.Error("Generated enum should contain IsValid method") + } + + if !containsString(code, "func (e *Status) UnmarshalJSON") { + t.Error("Generated enum should contain UnmarshalJSON method") + } + if !containsString(code, "func (e Status) MarshalJSON") { + t.Error("Generated enum should contain MarshalJSON method") + } +} + +func containsTypeDefinition(code, typeDef string) bool { + return containsString(code, typeDef) +} + +func containsImport(code, importPath string) bool { + return containsString(code, `"`+importPath+`"`) +} + +func containsString(haystack, needle string) bool { + // Use jsonschema import to avoid unused import error + _ = jsonschema.Schema{} + // Simple contains check + for i := 0; i <= len(haystack)-len(needle); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + +func TestGenerateArrayType(t *testing.T) { + tests := []struct { + name string + schema *config.Schema + typeName string + depth int + want string + wantContains []string + wantErr bool + }{ + { + name: "array of strings (top-level)", + schema: &config.Schema{ + Type: "array", + Description: "An array of tags", + Items: &config.Schema{ + Type: "string", + }, + }, + typeName: "Tags", + depth: 0, + wantContains: []string{"type Tags []string", "An array of tags"}, + wantErr: false, + }, + { + name: "array of strings (nested)", + schema: &config.Schema{ + Type: "array", + Items: &config.Schema{ + Type: "string", + }, + }, + typeName: "Tags", + depth: 1, + want: "[]string", + wantErr: false, + }, + { + name: "array of numbers (top-level)", + schema: &config.Schema{ + Type: "array", + Items: &config.Schema{ + Type: "number", + }, + }, + typeName: "Scores", + depth: 0, + wantContains: []string{"type Scores []float64"}, + wantErr: false, + }, + { + name: "array of numbers (nested)", + schema: &config.Schema{ + Type: "array", + Items: &config.Schema{ + Type: "number", + }, + }, + typeName: "Scores", + depth: 1, + want: "[]float64", + wantErr: false, + }, + { + name: "array of objects (ref, top-level)", + schema: &config.Schema{ + Type: "array", + Items: &config.Schema{ + Ref: "#/components/schemas/User", + }, + }, + typeName: "Users", + depth: 0, + wantContains: []string{"type Users []*User"}, + wantErr: false, + }, + { + name: "array of objects (ref, nested)", + schema: &config.Schema{ + Type: "array", + Items: &config.Schema{ + Ref: "#/components/schemas/User", + }, + }, + typeName: "Users", + depth: 1, + want: "[]*User", + wantErr: false, + }, + { + name: "array without items (top-level)", + schema: &config.Schema{ + Type: "array", + }, + typeName: "Unknown", + depth: 0, + wantContains: []string{"type Unknown []any"}, + wantErr: false, + }, + { + name: "array without items (nested)", + schema: &config.Schema{ + Type: "array", + }, + typeName: "Unknown", + depth: 1, + want: "[]any", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := NewTypeGenerator() + got, err := gen.generateArrayType(tt.typeName, tt.schema, tt.depth) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + if tt.want != "" { + // Exact match test + if got != tt.want { + t.Errorf("generateArrayType() = %q, want %q", got, tt.want) + } + } else { + // Contains test + for _, want := range tt.wantContains { + if !containsString(got, want) { + t.Errorf("generateArrayType() should contain %q\nGot: %s", want, got) + } + } + } + }) + } +} + +func TestGeneratePrimitiveTypeAlias(t *testing.T) { + tests := []struct { + name string + typeName string + schema *config.Schema + goType string + wantContains []string + }{ + { + name: "string with description", + typeName: "Username", + schema: &config.Schema{ + Description: "A username string", + }, + goType: "string", + wantContains: []string{"type Username string", "A username string"}, + }, + { + name: "integer without description", + typeName: "Count", + schema: &config.Schema{}, + goType: "int", + wantContains: []string{"type Count int", "Count represents a int schema"}, + }, + { + name: "float64 type", + typeName: "Score", + schema: &config.Schema{}, + goType: "float64", + wantContains: []string{"type Score float64"}, + }, + { + name: "boolean type", + typeName: "IsActive", + schema: &config.Schema{}, + goType: "bool", + wantContains: []string{"type IsActive bool"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := NewTypeGenerator() + got, err := gen.generatePrimitiveTypeAlias(tt.typeName, tt.schema, tt.goType) + if err != nil { + t.Errorf("generatePrimitiveTypeAlias() error = %v", err) + return + } + + for _, want := range tt.wantContains { + if !containsString(got, want) { + t.Errorf("generatePrimitiveTypeAlias() should contain %q\nGot: %s", want, got) + } + } + }) + } +} + +func TestGoTypeEdgeCases(t *testing.T) { + tests := []struct { + name string + setupGen func(*TypeGenerator) + schema *config.Schema + hint string + want string + wantErr bool + }{ + { + name: "object with title", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Type: "object", + Title: "CustomObject", + Properties: map[string]*config.Schema{ + "name": {Type: "string"}, + }, + }, + hint: "Field", + want: "CustomObject", + wantErr: false, + }, + { + name: "object with properties but no title", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "snapshot_id": {Type: "string"}, + }, + }, + hint: "Filter", + want: "Filter", + wantErr: false, + }, + { + name: "object without title or properties", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Type: "object", + }, + hint: "Metadata", + want: "map[string]any", + wantErr: false, + }, + { + name: "null type", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Type: "null", + }, + hint: "Value", + want: "any", + wantErr: false, + }, + { + name: "schema with only properties (no type)", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Properties: map[string]*config.Schema{ + "id": {Type: "string"}, + "name": {Type: "string"}, + }, + }, + hint: "User", + want: "User", + wantErr: false, + }, + { + name: "integer type", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Type: "integer", + }, + hint: "Count", + want: "int", + wantErr: false, + }, + { + name: "number type", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Type: "number", + }, + hint: "Price", + want: "float64", + wantErr: false, + }, + { + name: "boolean type", + setupGen: func(g *TypeGenerator) { + }, + schema: &config.Schema{ + Type: "boolean", + }, + hint: "Active", + want: "bool", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := NewTypeGenerator() + tt.setupGen(gen) + + got, err := gen.goType(tt.schema, tt.hint) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGoStringType(t *testing.T) { + tests := []struct { + name string + schema *config.Schema + want string + }{ + { + name: "date-time format", + schema: &config.Schema{Format: "date-time"}, + want: "time.Time", + }, + { + name: "date format", + schema: &config.Schema{Format: "date"}, + want: "string", + }, + { + name: "email format", + schema: &config.Schema{Format: "email"}, + want: "string", + }, + { + name: "uuid format", + schema: &config.Schema{Format: "uuid"}, + want: "string", + }, + { + name: "uri format", + schema: &config.Schema{Format: "uri"}, + want: "string", + }, + { + name: "no format", + schema: &config.Schema{}, + want: "string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := NewTypeGenerator() + got := gen.goStringType(tt.schema) + assert.Equal(t, tt.want, got) + + if tt.want == "time.Time" && !gen.imports["time"] { + t.Error("time.Time should add time import") + } + }) + } +} + +func TestGenerateStruct(t *testing.T) { + tests := []struct { + name string + schema *config.Schema + want []string // Strings that should be in the output + wantErr bool + }{ + { + name: "struct with description", + schema: &config.Schema{ + Description: "A user object", + Type: "object", + Properties: map[string]*config.Schema{ + "name": { + Type: "string", + Description: "User name", + }, + "age": { + Type: "integer", + }, + }, + Required: []string{"name"}, + }, + want: []string{ + "// A user object", + "type User struct", + "Name string", + "Age *int", // Age is not required, so it's a pointer + "`json:\"name\"`", + "`json:\"age,omitempty\"`", + }, + wantErr: false, + }, + { + name: "struct with title", + schema: &config.Schema{ + Title: "Person", + Type: "object", + Properties: map[string]*config.Schema{ + "id": {Type: "string"}, + }, + }, + want: []string{ + "// Person", + "type Person struct", + }, + wantErr: false, + }, + { + name: "struct with no description or title", + schema: &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "value": {Type: "string"}, + }, + }, + want: []string{ + "// Anonymous represents the schema", + "type Anonymous struct", + }, + wantErr: false, + }, + { + name: "struct with omittable on non-nullable field - should error", + schema: &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "name": { + Type: "string", + Extra: map[string]any{ + "go.probo.inc/mcpgen/omittable": true, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "struct with omittable on nullable field - should succeed", + schema: &config.Schema{ + Type: "object", + Properties: map[string]*config.Schema{ + "description": { + AnyOf: []*config.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + Extra: map[string]any{ + "go.probo.inc/mcpgen/omittable": true, + }, + }, + }, + }, + want: []string{ + "type UpdateInput struct", + "Description mcp.Omittable[*string]", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := NewTypeGenerator() + typeName := "User" + if tt.schema.Title != "" { + typeName = tt.schema.Title + } else if tt.name == "struct with no description or title" { + typeName = "Anonymous" + } else if tt.name == "struct with omittable on nullable field - should succeed" { + typeName = "UpdateInput" + } + + got, err := gen.generateStruct(typeName, tt.schema, 0) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + for _, wantStr := range tt.want { + assert.Contains(t, got, wantStr) + } + }) + } +} + +func TestToGoTypeName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"user", "User"}, + {"user_profile", "UserProfile"}, + {"user-settings", "UserSettings"}, + {"user.data", "UserData"}, + {"user_input.json", "User"}, // .json is stripped first, then _input + {"task_input_schema", "TaskInput"}, // only _schema is stripped as a suffix + {"task_schema", "Task"}, // _schema is stripped + {"my-cool-type", "MyCoolType"}, + {"id", "Id"}, + {"url", "Url"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toGoTypeName(tt.input) + if got != tt.want { + t.Errorf("toGoTypeName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestToGoFieldName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"id", "ID"}, + {"url", "URL"}, + {"uri", "URI"}, + {"api", "API"}, + {"json", "JSON"}, + {"xml", "XML"}, + {"html", "HTML"}, + {"http", "HTTP"}, + {"https", "HTTPS"}, + {"sql", "SQL"}, + {"ssh", "SSH"}, + {"tcp", "TCP"}, + {"udp", "UDP"}, + {"ip", "IP"}, + {"ui", "UI"}, + {"uuid", "UUID"}, + {"jwt", "JWT"}, + {"oauth", "OAuth"}, + {"snapshot_id", "SnapshotID"}, + {"user_id", "UserID"}, + {"account_id", "AccountID"}, + {"resource_url", "ResourceURL"}, + {"redirect_uri", "RedirectURI"}, + {"object_uuid", "ObjectUUID"}, + {"session_jwt", "SessionJWT"}, + {"api_key", "APIKey"}, + {"api_secret", "APISecret"}, + {"http_status", "HTTPStatus"}, + {"https_enabled", "HTTPSEnabled"}, + {"json_data", "JSONData"}, + {"xml_content", "XMLContent"}, + {"html_body", "HTMLBody"}, + {"sql_query", "SQLQuery"}, + {"ip_address", "IPAddress"}, + {"ui_state", "UIState"}, + {"user_api_key", "UserAPIKey"}, + {"get_json_data", "GetJSONData"}, + {"parse_xml_content", "ParseXMLContent"}, + {"api_url", "APIURL"}, + {"http_api_key", "HTTPAPIKey"}, + {"json_api_url", "JSONAPIURL"}, + {"user_name", "UserName"}, + {"first-name", "FirstName"}, + {"created_at", "CreatedAt"}, + {"is_active", "IsActive"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toGoFieldName(tt.input) + if got != tt.want { + t.Errorf("toGoFieldName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestToEnumConstName(t *testing.T) { + tests := []struct { + enumType string + value string + want string + }{ + {"Status", "pending", "StatusPending"}, + {"Status", "in_progress", "StatusInProgress"}, + {"ColorType", "red", "ColorRed"}, + {"Priority", "high-priority", "PriorityHighPriority"}, + } + + for _, tt := range tests { + t.Run(tt.enumType+"_"+tt.value, func(t *testing.T) { + got := toEnumConstName(tt.enumType, tt.value) + if got != tt.want { + t.Errorf("toEnumConstName(%q, %q) = %q, want %q", tt.enumType, tt.value, got, tt.want) + } + }) + } +} diff --git a/third_party/mcpgen/internal/config/config.go b/third_party/mcpgen/internal/config/config.go new file mode 100644 index 000000000..57064f428 --- /dev/null +++ b/third_party/mcpgen/internal/config/config.go @@ -0,0 +1,208 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/google/jsonschema-go/jsonschema" + "gopkg.in/yaml.v3" +) + +type Config struct { + Spec string `yaml:"spec" json:"spec"` + Output string `yaml:"output" json:"output"` + Exec ExecConfig `yaml:"exec,omitempty" json:"exec,omitempty"` + Resolver ResolverConfig `yaml:"resolver" json:"resolver"` + Model ModelConfig `yaml:"model,omitempty" json:"model,omitempty"` + Models ModelsConfig `yaml:"models,omitempty" json:"models,omitempty"` +} + +type ExecConfig struct { + Package string `yaml:"package,omitempty" json:"package,omitempty"` + Filename string `yaml:"filename,omitempty" json:"filename,omitempty"` +} + +type ResolverConfig struct { + Package string `yaml:"package" json:"package"` + Filename string `yaml:"filename" json:"filename"` + Type string `yaml:"type" json:"type"` + Preserve bool `yaml:"preserve" json:"preserve"` +} + +type ModelConfig struct { + Package string `yaml:"package,omitempty" json:"package,omitempty"` + Filename string `yaml:"filename,omitempty" json:"filename,omitempty"` +} + +type ModelsConfig struct { + // Map schema names to custom Go types + // Example: User: github.com/myorg/models.User + Models map[string]TypeMapping `yaml:",inline,omitempty" json:",inline,omitempty"` +} + +type TypeMapping struct { + // Model is the fully qualified Go type to use + // Example: github.com/google/uuid.UUID + Model string `yaml:"model" json:"model"` +} + +type ServerInfo struct { + Title string `yaml:"title" json:"title"` + Version string `yaml:"version" json:"version"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` +} + +type Components struct { + Schemas map[string]*jsonschema.Schema `yaml:"schemas,omitempty" json:"schemas,omitempty"` +} + +type Schema = jsonschema.Schema + +type ToolHints struct { + Readonly bool `yaml:"readonly,omitempty" json:"readonly,omitempty"` + Destructive bool `yaml:"destructive,omitempty" json:"destructive,omitempty"` + Idempotent bool `yaml:"idempotent,omitempty" json:"idempotent,omitempty"` + OpenWorld bool `yaml:"openWorld,omitempty" json:"openWorld,omitempty"` +} + +type Tool struct { + Name string `yaml:"name" json:"name"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + InputSchema *Schema `yaml:"inputSchema" json:"inputSchema"` + OutputSchema *Schema `yaml:"outputSchema,omitempty" json:"outputSchema,omitempty"` + Hints *ToolHints `yaml:"hints,omitempty" json:"hints,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"` + Handler string `yaml:"handler,omitempty" json:"handler,omitempty"` +} + +type Resource struct { + URI string `yaml:"uri,omitempty" json:"uri,omitempty"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + MimeType string `yaml:"mimeType,omitempty" json:"mimeType,omitempty"` + URITemplate string `yaml:"uriTemplate,omitempty" json:"uriTemplate,omitempty"` + Schema *Schema `yaml:"schema,omitempty" json:"schema,omitempty"` + Readonly bool `yaml:"readonly,omitempty" json:"readonly,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"` + Handler string `yaml:"handler,omitempty" json:"handler,omitempty"` +} + +type Prompt struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Arguments []PromptArgument `yaml:"arguments,omitempty" json:"arguments,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"` + Handler string `yaml:"handler,omitempty" json:"handler,omitempty"` +} + +type PromptArgument struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Required bool `yaml:"required,omitempty" json:"required,omitempty"` +} + +func Load(path string) (*Config, *MCPSpec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("failed to read config file: %w", err) + } + + config := &Config{ + Spec: "schema.yaml", + Output: "generated", + Exec: ExecConfig{ + Package: "server", + Filename: "server/server.go", + }, + Resolver: ResolverConfig{ + Package: "generated", + Filename: "resolver.go", + Type: "Resolver", + Preserve: true, + }, + Model: ModelConfig{ + Package: "generated", + Filename: "models.go", + }, + } + + ext := filepath.Ext(path) + switch ext { + case ".yaml", ".yml": + if err := yaml.Unmarshal(data, config); err != nil { + return nil, nil, fmt.Errorf("failed to parse YAML config: %w", err) + } + case ".json": + if err := json.Unmarshal(data, config); err != nil { + return nil, nil, fmt.Errorf("failed to parse JSON config: %w", err) + } + default: + return nil, nil, fmt.Errorf("unsupported config file format: %s (use .yaml, .yml, or .json)", ext) + } + + if err := config.Validate(); err != nil { + return nil, nil, fmt.Errorf("invalid configuration: %w", err) + } + + // Make output path absolute relative to config file directory + configDir := filepath.Dir(path) + if !filepath.IsAbs(config.Output) { + config.Output = filepath.Join(configDir, config.Output) + } + + specPath := config.Spec + if !filepath.IsAbs(specPath) { + configDir := filepath.Dir(path) + specPath = filepath.Join(configDir, specPath) + } + + if _, err := os.Stat(specPath); os.IsNotExist(err) { + basePath := specPath + for _, ext := range []string{".yaml", ".yml", ".json"} { + tryPath := basePath + if filepath.Ext(tryPath) == "" { + tryPath = basePath + ext + } else { + tryPath = basePath[:len(basePath)-len(filepath.Ext(basePath))] + ext + } + if _, err := os.Stat(tryPath); err == nil { + specPath = tryPath + break + } + } + } + + spec, err := LoadMCPSpec(specPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to load MCP spec from %s: %w", specPath, err) + } + + return config, spec, nil +} + +func (c *Config) Validate() error { + if c.Spec == "" { + return fmt.Errorf("spec path is required") + } + if c.Output == "" { + return fmt.Errorf("output is required") + } + if c.Exec.Package == "" { + return fmt.Errorf("exec.package is required") + } + if c.Resolver.Package == "" { + return fmt.Errorf("resolver.package is required") + } + if c.Model.Package == "" { + return fmt.Errorf("model.package is required") + } + + return nil +} + +func IsSchemaRef(s *Schema) bool { + return s != nil && s.Ref != "" +} diff --git a/third_party/mcpgen/internal/config/spec.go b/third_party/mcpgen/internal/config/spec.go new file mode 100644 index 000000000..c949d465f --- /dev/null +++ b/third_party/mcpgen/internal/config/spec.go @@ -0,0 +1,114 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type MCPSpec struct { + Info ServerInfo `yaml:"info" json:"info"` + Components Components `yaml:"components,omitempty" json:"components,omitempty"` + Tools []Tool `yaml:"tools,omitempty" json:"tools,omitempty"` + Resources []Resource `yaml:"resources,omitempty" json:"resources,omitempty"` + Prompts []Prompt `yaml:"prompts,omitempty" json:"prompts,omitempty"` +} + +func LoadMCPSpec(path string) (*MCPSpec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read MCP spec file: %w", err) + } + + spec := &MCPSpec{} + + ext := filepath.Ext(path) + switch ext { + case ".yaml", ".yml": + var intermediate interface{} + if err := yaml.Unmarshal(data, &intermediate); err != nil { + return nil, fmt.Errorf("failed to parse YAML spec: %w", err) + } + jsonData, err := json.Marshal(intermediate) + if err != nil { + return nil, fmt.Errorf("failed to convert YAML to JSON: %w", err) + } + if err := json.Unmarshal(jsonData, spec); err != nil { + return nil, fmt.Errorf("failed to unmarshal spec: %w", err) + } + case ".json": + if err := json.Unmarshal(data, spec); err != nil { + return nil, fmt.Errorf("failed to parse JSON spec: %w", err) + } + default: + return nil, fmt.Errorf("unsupported spec file format: %s (use .yaml, .yml, or .json)", ext) + } + + if err := spec.Validate(); err != nil { + return nil, fmt.Errorf("invalid MCP specification: %w", err) + } + + return spec, nil +} + +func (s *MCPSpec) Validate() error { + if s.Info.Title == "" { + return fmt.Errorf("info.title is required") + } + if s.Info.Version == "" { + return fmt.Errorf("info.version is required") + } + + for i, tool := range s.Tools { + if tool.Name == "" { + return fmt.Errorf("tools[%d].name is required", i) + } + if tool.InputSchema == nil { + return fmt.Errorf("tools[%d].inputSchema is required", i) + } + } + + for i, resource := range s.Resources { + if resource.Name == "" { + return fmt.Errorf("resources[%d].name is required", i) + } + if resource.URI == "" && resource.URITemplate == "" { + return fmt.Errorf("resources[%d] must have either uri or uriTemplate", i) + } + if resource.URI != "" && resource.URITemplate != "" { + return fmt.Errorf("resources[%d] cannot have both uri and uriTemplate", i) + } + } + + for i, prompt := range s.Prompts { + if prompt.Name == "" { + return fmt.Errorf("prompts[%d].name is required", i) + } + } + + return nil +} + +func (s *MCPSpec) ResolveSchemaRef(ref string) (*Schema, error) { + if len(ref) > 0 && ref[0] == '#' { + if ref == "#/components/schemas" { + return nil, fmt.Errorf("incomplete schema reference: %s", ref) + } + + const prefix = "#/components/schemas/" + if len(ref) > len(prefix) && ref[:len(prefix)] == prefix { + schemaName := ref[len(prefix):] + if schema, ok := s.Components.Schemas[schemaName]; ok { + return schema, nil + } + return nil, fmt.Errorf("schema not found: %s", schemaName) + } + + return nil, fmt.Errorf("unsupported reference format: %s", ref) + } + + return nil, nil +} diff --git a/third_party/mcpgen/internal/schema/schema.go b/third_party/mcpgen/internal/schema/schema.go new file mode 100644 index 000000000..a185941aa --- /dev/null +++ b/third_party/mcpgen/internal/schema/schema.go @@ -0,0 +1,85 @@ +package schema + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/google/jsonschema-go/jsonschema" +) + +type Schema = jsonschema.Schema + +type Loader struct { + schemas map[string]*Schema + baseDir string +} + +func NewLoader(baseDir string) *Loader { + return &Loader{ + schemas: make(map[string]*Schema), + baseDir: baseDir, + } +} + +func (l *Loader) Load(path string) (*Schema, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + if schema, ok := l.schemas[absPath]; ok { + return schema, nil + } + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read schema file %s: %w", path, err) + } + + var schema Schema + if err := json.Unmarshal(data, &schema); err != nil { + return nil, fmt.Errorf("failed to parse schema file %s: %w", path, err) + } + + l.schemas[absPath] = &schema + + return &schema, nil +} + +func GetType(s *Schema) string { + if s.Type != "" { + return s.Type + } + if len(s.Types) > 0 { + return s.Types[0] + } + return "" +} + +func IsRequired(s *Schema, propName string) bool { + for _, req := range s.Required { + if req == propName { + return true + } + } + return false +} + +// IsOmittable checks if a schema property has the go.probo.inc/mcpgen/omittable annotation set to true. +// This is used to wrap fields in mcp.Omittable[T] to distinguish between +// "not set", "set to null", and "set to value". +func IsOmittable(s *Schema) bool { + if s == nil || s.Extra == nil { + return false + } + + if omittable, ok := s.Extra["go.probo.inc/mcpgen/omittable"]; ok { + if omittableBool, ok := omittable.(bool); ok { + return omittableBool + } + } + + return false +} diff --git a/third_party/mcpgen/main.go b/third_party/mcpgen/main.go new file mode 100644 index 000000000..9c31f3acf --- /dev/null +++ b/third_party/mcpgen/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "go.probo.inc/mcpgen/internal/codegen" + "go.probo.inc/mcpgen/internal/config" +) + +var version = "dev" + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var rootCmd = &cobra.Command{ + Use: "mcpgen", + Short: "A code generator for Model Context Protocol (MCP) servers", + Long: `mcpgen is a gqlgen-like code generator for building MCP servers in Go. +It generates type-safe Go code from JSON Schema definitions for tools, resources, and prompts.`, +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of mcpgen", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("mcpgen %s\n", version) + }, +} + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate Go code from mcpgen configuration", + Long: `Reads mcpgen.yaml (or mcpgen.yml) configuration file and generates: + - Type-safe Go structs from JSON Schemas + - MCP server boilerplate code + - Handler function stubs for tools, resources, and prompts`, + RunE: func(cmd *cobra.Command, args []string) error { + configFile, _ := cmd.Flags().GetString("config") + return runGenerate(configFile) + }, +} + +var initCmd = &cobra.Command{ + Use: "init [name]", + Short: "Initialize a new MCP server project", + Long: `Creates a new MCP server project with example configuration and file structure.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := "my-mcp-server" + if len(args) > 0 { + name = args[0] + } + return runInit(name) + }, +} + +func init() { + generateCmd.Flags().StringP("config", "c", "mcpgen.yaml", "Path to config file") + + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(generateCmd) + rootCmd.AddCommand(initCmd) +} + +func runGenerate(configFile string) error { + if _, err := os.Stat(configFile); os.IsNotExist(err) { + if configFile == "mcpgen.yaml" { + if _, err := os.Stat("mcpgen.yml"); err == nil { + configFile = "mcpgen.yml" + } + } + } + + fmt.Printf("Loading configuration from %s...\n", configFile) + + cfg, spec, err := config.Load(configFile) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + fmt.Printf("Generating code for %s v%s...\n", spec.Info.Title, spec.Info.Version) + + gen := codegen.New(cfg, spec) + + if err := gen.Generate(); err != nil { + return fmt.Errorf("code generation failed: %w", err) + } + + fmt.Println("āœ“ Code generation completed successfully!") + return nil +} + +func runInit(name string) error { + fmt.Printf("Initializing new MCP server project: %s\n", name) + + if err := os.MkdirAll(name, 0755); err != nil { + return fmt.Errorf("failed to create project directory: %w", err) + } + + configContent := `# mcpgen configuration +# Path to MCP API specification +spec: schema.yaml + +# Output directory for generated code +output: generated + +# Resolver configuration +resolver: + package: generated + filename: resolver.go + type: Resolver + preserve: true + +# Model configuration +model: + package: generated + filename: models.go +` + + configPath := filepath.Join(name, "mcpgen.yaml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + schemaContent := fmt.Sprintf(`# MCP API Specification +# This file contains the pure MCP API definition + +info: + title: %s + version: 1.0.0 + description: An example MCP server + +# Reusable schema components +components: + schemas: + ExampleInput: + type: object + properties: + message: + type: string + description: The message to process + required: [message] + +# MCP Tools +tools: + - name: example_tool + title: Example Tool + description: An example tool that processes messages + hints: + readonly: false + destructive: false + idempotent: true + inputSchema: + $ref: "#/components/schemas/ExampleInput" + +# MCP Resources +resources: [] + +# MCP Prompts +prompts: [] +`, name) + + schemaPath := filepath.Join(name, "schema.yaml") + if err := os.WriteFile(schemaPath, []byte(schemaContent), 0644); err != nil { + return fmt.Errorf("failed to write schema file: %w", err) + } + + fmt.Printf("\nāœ“ Project initialized successfully!\n\n") + fmt.Printf("Files created:\n") + fmt.Printf(" - mcpgen.yaml (code generation configuration)\n") + fmt.Printf(" - schema.yaml (MCP API specification)\n\n") + fmt.Printf("Next steps:\n") + fmt.Printf(" cd %s\n", name) + fmt.Printf(" # Edit schema.yaml to define your tools, resources, and prompts\n") + fmt.Printf(" mcpgen generate\n") + + return nil +} diff --git a/third_party/mcpgen/mcp/omittable.go b/third_party/mcpgen/mcp/omittable.go new file mode 100644 index 000000000..1097cccc5 --- /dev/null +++ b/third_party/mcpgen/mcp/omittable.go @@ -0,0 +1,117 @@ +package mcp + +import ( + "encoding/json" + "fmt" +) + +// Omittable represents a value that can be in one of three states: +// 1. Not set (field was not provided in JSON) +// 2. Explicitly set to null +// 3. Set to a value +// +// This is useful for distinguishing between "don't update this field" (not set) +// and "set this field to null" (explicitly null) in update operations. +// +// Example usage: +// +// type UpdateUserInput struct { +// Name Omittable[string] `json:"name,omitempty"` +// Email Omittable[string] `json:"email,omitempty"` +// } +// +// func (r *Resolver) UpdateUser(input UpdateUserInput) { +// if input.Name.IsSet() { +// if input.Name.IsNull() { +// // Set name to null +// } else { +// // Update name to input.Name.Value() +// } +// } +// // If !IsSet(), don't touch the name field +// } +type Omittable[T any] struct { + value *T + isSet bool +} + +func NewOmittable[T any](value T) Omittable[T] { + return Omittable[T]{ + value: &value, + isSet: true, + } +} + +func NewOmittableNull[T any]() Omittable[T] { + return Omittable[T]{ + value: nil, + isSet: true, + } +} + +// IsSet returns true if the field was provided in the input (either null or a value). +func (o Omittable[T]) IsSet() bool { + return o.isSet +} + +// IsNull returns true if the field was explicitly set to null. +// Returns false if the field was not set or has a value. +func (o Omittable[T]) IsNull() bool { + return o.isSet && o.value == nil +} + +// Value returns the value and a boolean indicating if it has a non-null value. +// If the field is not set or is null, returns the zero value and false. +func (o Omittable[T]) Value() (T, bool) { + if o.value != nil { + return *o.value, true + } + var zero T + return zero, false +} + +func (o Omittable[T]) ValueOrZero() T { + if o.value != nil { + return *o.value + } + var zero T + return zero +} + +func (o Omittable[T]) Ptr() *T { + return o.value +} + +// UnmarshalJSON implements json.Unmarshaler. +func (o *Omittable[T]) UnmarshalJSON(data []byte) error { + o.isSet = true + + // Handle explicit null + if string(data) == "null" { + o.value = nil + return nil + } + + // Unmarshal the actual value + var value T + if err := json.Unmarshal(data, &value); err != nil { + return fmt.Errorf("failed to unmarshal omittable value: %w", err) + } + + o.value = &value + return nil +} + +// MarshalJSON implements json.Marshaler. +func (o Omittable[T]) MarshalJSON() ([]byte, error) { + // Note: When marshaling structs with Omittable fields, use *Omittable[T] + // if you need omitempty to work correctly. With value types, omitempty + // doesn't work well with custom MarshalJSON. + // For MCP use cases (unmarshaling input), this is not typically an issue. + + if !o.isSet || o.value == nil { + return []byte("null"), nil + } + + return json.Marshal(*o.value) +} diff --git a/third_party/mcpgen/mcp/omittable_test.go b/third_party/mcpgen/mcp/omittable_test.go new file mode 100644 index 000000000..d17c13be9 --- /dev/null +++ b/third_party/mcpgen/mcp/omittable_test.go @@ -0,0 +1,201 @@ +package mcp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOmittable_NotSet(t *testing.T) { + var o Omittable[string] + + assert.False(t, o.IsSet()) + assert.False(t, o.IsNull()) + + value, ok := o.Value() + assert.False(t, ok) + assert.Equal(t, "", value) +} + +func TestOmittable_SetToValue(t *testing.T) { + o := NewOmittable("hello") + + assert.True(t, o.IsSet()) + assert.False(t, o.IsNull()) + + value, ok := o.Value() + assert.True(t, ok) + assert.Equal(t, "hello", value) + assert.Equal(t, "hello", o.ValueOrZero()) + + ptr := o.Ptr() + require.NotNil(t, ptr) + assert.Equal(t, "hello", *ptr) +} + +func TestOmittable_SetToNull(t *testing.T) { + o := NewOmittableNull[string]() + + assert.True(t, o.IsSet()) + assert.True(t, o.IsNull()) + + value, ok := o.Value() + assert.False(t, ok) + assert.Equal(t, "", value) + assert.Equal(t, "", o.ValueOrZero()) + assert.Nil(t, o.Ptr()) +} + +func TestOmittable_UnmarshalJSON_NotProvided(t *testing.T) { + type Input struct { + Name Omittable[string] `json:"name,omitempty"` + Email Omittable[string] `json:"email,omitempty"` + } + + jsonData := `{"name": "John"}` + var input Input + require.NoError(t, json.Unmarshal([]byte(jsonData), &input)) + + assert.True(t, input.Name.IsSet()) + name, ok := input.Name.Value() + assert.True(t, ok) + assert.Equal(t, "John", name) + + assert.False(t, input.Email.IsSet()) +} + +func TestOmittable_UnmarshalJSON_ExplicitNull(t *testing.T) { + type Input struct { + Name Omittable[string] `json:"name,omitempty"` + Email Omittable[string] `json:"email,omitempty"` + } + + jsonData := `{"name": null, "email": "test@example.com"}` + var input Input + require.NoError(t, json.Unmarshal([]byte(jsonData), &input)) + + assert.True(t, input.Name.IsSet()) + assert.True(t, input.Name.IsNull()) + + assert.True(t, input.Email.IsSet()) + email, ok := input.Email.Value() + assert.True(t, ok) + assert.Equal(t, "test@example.com", email) +} + +func TestOmittable_UnmarshalJSON_WithValue(t *testing.T) { + type Input struct { + Count Omittable[int] `json:"count,omitempty"` + } + + jsonData := `{"count": 42}` + var input Input + require.NoError(t, json.Unmarshal([]byte(jsonData), &input)) + + assert.True(t, input.Count.IsSet()) + assert.False(t, input.Count.IsNull()) + count, ok := input.Count.Value() + assert.True(t, ok) + assert.Equal(t, 42, count) +} + +func TestOmittable_MarshalJSON_NotSet(t *testing.T) { + type Output struct { + Name Omittable[string] `json:"name,omitempty"` + } + + output := Output{} + data, err := json.Marshal(output) + require.NoError(t, err) + + assert.JSONEq(t, `{"name":null}`, string(data)) +} + +func TestOmittable_MarshalJSON_Null(t *testing.T) { + type Output struct { + Name Omittable[string] `json:"name,omitempty"` + } + + output := Output{ + Name: NewOmittableNull[string](), + } + data, err := json.Marshal(output) + require.NoError(t, err) + + assert.JSONEq(t, `{"name":null}`, string(data)) +} + +func TestOmittable_MarshalJSON_WithValue(t *testing.T) { + type Output struct { + Name Omittable[string] `json:"name,omitempty"` + } + + output := Output{ + Name: NewOmittable("Alice"), + } + data, err := json.Marshal(output) + require.NoError(t, err) + + assert.JSONEq(t, `{"name":"Alice"}`, string(data)) +} + +func TestOmittable_ComplexTypes(t *testing.T) { + type Person struct { + Name string `json:"name"` + Age int `json:"age"` + } + + type Input struct { + Person Omittable[Person] `json:"person,omitempty"` + } + + t.Run("with value", func(t *testing.T) { + jsonData := `{"person": {"name": "John", "age": 30}}` + var input Input + require.NoError(t, json.Unmarshal([]byte(jsonData), &input)) + + assert.True(t, input.Person.IsSet()) + person, ok := input.Person.Value() + assert.True(t, ok) + assert.Equal(t, "John", person.Name) + assert.Equal(t, 30, person.Age) + }) + + t.Run("with null", func(t *testing.T) { + jsonData := `{"person": null}` + var input Input + require.NoError(t, json.Unmarshal([]byte(jsonData), &input)) + + assert.True(t, input.Person.IsSet()) + assert.True(t, input.Person.IsNull()) + }) +} + +func TestOmittable_Pointers(t *testing.T) { + type Input struct { + Name Omittable[*string] `json:"name,omitempty"` + } + + t.Run("with value", func(t *testing.T) { + jsonData := `{"name": "hello"}` + var input Input + require.NoError(t, json.Unmarshal([]byte(jsonData), &input)) + + assert.True(t, input.Name.IsSet()) + value, ok := input.Name.Value() + assert.True(t, ok) + require.NotNil(t, value) + assert.Equal(t, "hello", *value) + }) + + t.Run("with null", func(t *testing.T) { + jsonData := `{"name": null}` + var input Input + require.NoError(t, json.Unmarshal([]byte(jsonData), &input)) + + assert.True(t, input.Name.IsSet()) + assert.True(t, input.Name.IsNull()) + }) +} diff --git a/third_party/mcpgen/mcp/recover.go b/third_party/mcpgen/mcp/recover.go new file mode 100644 index 000000000..5ea5be0dc --- /dev/null +++ b/third_party/mcpgen/mcp/recover.go @@ -0,0 +1,63 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "os" + "runtime/debug" +) + +// RecoverFunc is called when a tool handler panics. It receives the recovered +// value (whatever was passed to panic) and returns an error to be reported to +// the client. +// +// This matches the signature and semantics of gqlgen's RecoverFunc. +// +// Example: +// +// server.New(resolver, server.WithRecoverFunc(func(ctx context.Context, err any) error { +// log.Error("tool panic", "err", err) +// return errors.New("internal server error") +// })) +type RecoverFunc func(ctx context.Context, err any) error + +// DefaultRecoverFunc prints the panic and stack trace to stderr and returns a +// generic internal error. This matches gqlgen's DefaultRecover behavior. +func DefaultRecoverFunc(_ context.Context, err any) error { + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr) + debug.PrintStack() + return errors.New("internal system error") +} + +// Option configures the generated MCP server. +type Option func(*Options) + +// Options holds configuration for the generated MCP server. +type Options struct { + RecoverFunc RecoverFunc +} + +// WithRecoverFunc sets the panic recover function for tool handlers. +// The recover function is called when a tool handler panics, and its return +// value is sent to the client in place of the panic. +func WithRecoverFunc(fn RecoverFunc) Option { + return func(o *Options) { + o.RecoverFunc = fn + } +} + +// ApplyOptions applies the given options to an Options struct. +// If RecoverFunc is nil after applying options, it is set to DefaultRecoverFunc: +// recovery is always enabled, matching gqlgen's behavior. +func ApplyOptions(opts []Option) Options { + var o Options + for _, opt := range opts { + opt(&o) + } + if o.RecoverFunc == nil { + o.RecoverFunc = DefaultRecoverFunc + } + return o +} diff --git a/third_party/mcpgen/mcp/recover_test.go b/third_party/mcpgen/mcp/recover_test.go new file mode 100644 index 000000000..f029af308 --- /dev/null +++ b/third_party/mcpgen/mcp/recover_test.go @@ -0,0 +1,51 @@ +package mcp + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestApplyOptions(t *testing.T) { + t.Run("no options uses default recover func", func(t *testing.T) { + opts := ApplyOptions(nil) + assert.NotNil(t, opts.RecoverFunc) + }) + + t.Run("nil recover func falls back to default", func(t *testing.T) { + opts := ApplyOptions([]Option{WithRecoverFunc(nil)}) + assert.NotNil(t, opts.RecoverFunc) + }) + + t.Run("with custom recover func", func(t *testing.T) { + fn := func(_ context.Context, _ any) error { + return errors.New("sanitized") + } + opts := ApplyOptions([]Option{WithRecoverFunc(fn)}) + assert.NotNil(t, opts.RecoverFunc) + + err := opts.RecoverFunc(context.Background(), "boom") + assert.Equal(t, "sanitized", err.Error()) + }) + + t.Run("recover func receives raw panic value", func(t *testing.T) { + var captured any + fn := func(_ context.Context, err any) error { + captured = err + return nil + } + opts := ApplyOptions([]Option{WithRecoverFunc(fn)}) + + opts.RecoverFunc(context.Background(), 42) + assert.Equal(t, 42, captured) + + opts.RecoverFunc(context.Background(), "string panic") + assert.Equal(t, "string panic", captured) + + original := errors.New("error panic") + opts.RecoverFunc(context.Background(), original) + assert.Equal(t, original, captured) + }) +} diff --git a/third_party/mcpgen/mcp/schema.go b/third_party/mcpgen/mcp/schema.go new file mode 100644 index 000000000..4b1358877 --- /dev/null +++ b/third_party/mcpgen/mcp/schema.go @@ -0,0 +1,77 @@ +package mcp + +import ( + "context" + "encoding/json" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MustUnmarshalSchema unmarshals a JSON schema string into a jsonschema.Schema +// Panics if unmarshaling fails, providing compile-time safety for schema definitions +func MustUnmarshalSchema(schemaJSON string) *jsonschema.Schema { + var schema jsonschema.Schema + if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil { + panic("invalid schema JSON: " + err.Error()) + } + return &schema +} + +// PromptHandlerFor is a typed prompt handler that accepts structured arguments. +// Similar to mcp.ToolHandlerFor, this allows prompts to work with typed Go structs +// instead of raw map[string]string. +// +// The Args type parameter must be a struct or map type. Arguments will be automatically +// unmarshaled from the prompt request's Arguments map into the Args type. +// +// Example: +// +// type TaskArgs struct { +// Topic string `json:"topic"` +// Detailed bool `json:"detailed"` +// } +// +// func (r *Resolver) TaskHelpPrompt(ctx context.Context, req *mcp.GetPromptRequest, args TaskArgs) (*mcp.GetPromptResult, error) { +// // args.Topic and args.Detailed are already parsed +// return &mcp.GetPromptResult{...}, nil +// } +type PromptHandlerFor[Args any] func(context.Context, *mcp.GetPromptRequest, Args) (*mcp.GetPromptResult, error) + +// AddPrompt is a generic wrapper around Server.AddPrompt that provides type-safe argument handling. +// It automatically converts the prompt arguments from map[string]string into the typed Args parameter. +// +// This matches the ergonomics of mcp.AddTool for a consistent API experience across tools and prompts. +// +// The Args type must be a struct with string fields or map[string]string. If it's a struct, the fields +// will be populated from the arguments map based on their json tags. +// +// Example: +// +// type HelpArgs struct { +// Topic string `json:"topic"` +// } +// +// mcp.AddPrompt(server, &mcp.Prompt{ +// Name: "help", +// Description: "Get help", +// }, resolver.HelpPrompt) // HelpPrompt receives typed HelpArgs +func AddPrompt[Args any](s *mcp.Server, p *mcp.Prompt, h PromptHandlerFor[Args]) { + s.AddPrompt(p, func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + var args Args + + // Convert map[string]string to typed Args using JSON as the intermediary. + // This properly handles json tags and field mapping. + if len(req.Params.Arguments) > 0 { + argsBytes, err := json.Marshal(req.Params.Arguments) + if err != nil { + return nil, err + } + if err := json.Unmarshal(argsBytes, &args); err != nil { + return nil, err + } + } + + return h(ctx, req, args) + }) +}