Add per-folder CLAUDE.md for key packages
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
57
apps/console/CLAUDE.md
Normal file
57
apps/console/CLAUDE.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# apps/console
|
||||||
|
|
||||||
|
React 19 + Vite + TypeScript + Relay + TailwindCSS. Port 5173.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `npm run dev` | Start dev server (port 5173) |
|
||||||
|
| `npm run build` | Production build |
|
||||||
|
| `npx relay-compiler` | Regenerate Relay artifacts |
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
Defined in `src/routes.tsx` with feature-specific route files (e.g. `src/routes/assetRoutes.ts`).
|
||||||
|
|
||||||
|
- Lazy-loaded via `lazy()` from `@probo/react-lazy`
|
||||||
|
- Data loading: `loaderFromQueryLoader()` + `loadQuery()` (Relay)
|
||||||
|
- Type: all routes `satisfies AppRoute[]`
|
||||||
|
- Fallback: `PageSkeleton` or `Fallback` components during loading
|
||||||
|
- Error boundaries per route group
|
||||||
|
|
||||||
|
## Relay
|
||||||
|
|
||||||
|
Queries, fragments, and mutations are **colocated** in the component that uses them — never in separate `hooks/graph/` files.
|
||||||
|
|
||||||
|
Always use **fragments** to define data requirements. Never create custom TypeScript types for API data — let Relay generate types from fragments.
|
||||||
|
|
||||||
|
### Mutations
|
||||||
|
|
||||||
|
Use `@appendEdge` / `@deleteEdge` directives for Relay store updates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const createAssetMutation = graphql`
|
||||||
|
mutation AssetCreateMutation($input: CreateAssetInput!, $connections: [ID!]!) {
|
||||||
|
createAsset(input: $input) {
|
||||||
|
assetEdge @appendEdge(connections: $connections) {
|
||||||
|
node { id name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Inline permission queries in Relay fragments:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
canCreate: permission(action: "core:asset:create")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- Form fields: `src/components/form/`
|
||||||
|
- Dialogs: modal components with mutation handling
|
||||||
|
- Shared UI: `@probo/ui` package
|
||||||
15
apps/trust/CLAUDE.md
Normal file
15
apps/trust/CLAUDE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# apps/trust
|
||||||
|
|
||||||
|
React 19 + Vite + TypeScript + Relay + TailwindCSS. Port 5174.
|
||||||
|
|
||||||
|
Same frontend stack as `apps/console/` — see its CLAUDE.md for Relay query patterns, mutation hooks, and component conventions.
|
||||||
|
|
||||||
|
## Trust-specific differences
|
||||||
|
|
||||||
|
- Public-facing trust center app (not an internal dashboard)
|
||||||
|
- Path-prefix routing: `/trust/{slug}` for Probo-hosted, `/` for custom domains
|
||||||
|
- Routes: `/overview`, `/documents`, `/subprocessors`, `/updates`
|
||||||
|
- Auth flow: `/connect`, `/verify-magic-link`, `/full-name`
|
||||||
|
- Content routes (`/overview`, `/documents`, `/subprocessors`, `/updates`) wrapped in `MainLayout`
|
||||||
|
- Auth routes (`/connect`, `/verify-magic-link`, `/full-name`) wrapped in `AuthLayout`
|
||||||
|
- All route groups use `RootErrorBoundary`
|
||||||
48
e2e/CLAUDE.md
Normal file
48
e2e/CLAUDE.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# e2e
|
||||||
|
|
||||||
|
End-to-end tests against a running `bin/probod` instance.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Build the binary first: `make build` (or `SKIP_APPS=1 make build` for backend-only).
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```
|
||||||
|
make test-e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test setup
|
||||||
|
|
||||||
|
`testutil.Setup()` starts `bin/probod` as a subprocess (once per test run) and waits for the GraphQL endpoint to be healthy.
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
```go
|
||||||
|
c := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
```
|
||||||
|
|
||||||
|
The client carries organization/tenant context and provides:
|
||||||
|
- `c.Execute(query, variables, &result)` — GraphQL queries
|
||||||
|
- `c.ExecuteConnect(query, variables, &result)` — Connect API queries
|
||||||
|
- `c.GetOrganizationID()` — current org
|
||||||
|
|
||||||
|
## Factory pattern
|
||||||
|
|
||||||
|
Test data created via `factory.Create*` functions:
|
||||||
|
|
||||||
|
```go
|
||||||
|
vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"})
|
||||||
|
userID := factory.CreateUser(c)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `factory.SafeName(prefix)` — random unique names
|
||||||
|
- `factory.SafeEmail()` — random unique emails
|
||||||
|
- `factory.Attrs` map for overriding defaults
|
||||||
|
|
||||||
|
## Test structure
|
||||||
|
|
||||||
|
- Always `t.Parallel()` at the test function level
|
||||||
|
- Inline GraphQL queries as string constants
|
||||||
|
- Typed result structs per query
|
||||||
|
- `require.NoError` for mutation/query errors, `assert.Equal` for value checks
|
||||||
48
pkg/agent/CLAUDE.md
Normal file
48
pkg/agent/CLAUDE.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# pkg/agent
|
||||||
|
|
||||||
|
LLM agent orchestration framework.
|
||||||
|
|
||||||
|
## Agent construction
|
||||||
|
|
||||||
|
```go
|
||||||
|
agent := agent.NewAgent(
|
||||||
|
"agent-name",
|
||||||
|
"System instructions here",
|
||||||
|
agent.WithTools(tool1, tool2),
|
||||||
|
agent.WithHandoffs(otherAgent),
|
||||||
|
agent.WithModel(model),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Functional options: `WithTools`, `WithHandoffs`, `WithInstructions`, `WithModel`, `WithModelSettings`, `WithMCPServers`, `WithInputGuardrails`, `WithOutputGuardrails`, `WithApproval`, `WithSession`.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := agent.Run(ctx, messages)
|
||||||
|
result.FinalMessage().Text() // final output
|
||||||
|
result.LastAgent // agent that produced the result
|
||||||
|
```
|
||||||
|
|
||||||
|
Typed output via `RunTyped[T](ctx, agent, messages)` — validates against JSON Schema.
|
||||||
|
|
||||||
|
## Tool interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Tool interface {
|
||||||
|
Name() string
|
||||||
|
Description() string
|
||||||
|
Parameters() jsonschema.Schema
|
||||||
|
Execute(ctx context.Context, input json.RawMessage) (string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent-as-tool
|
||||||
|
|
||||||
|
`agent.AsTool(name, description)` wraps an agent as a tool for composition.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- Max turns: 10 (default)
|
||||||
|
- Max tool depth: 16 (default)
|
||||||
|
- Depth tracking prevents infinite recursion in handoffs
|
||||||
164
pkg/coredata/CLAUDE.md
Normal file
164
pkg/coredata/CLAUDE.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# pkg/coredata
|
||||||
|
|
||||||
|
All raw SQL lives here — never in service or handler packages.
|
||||||
|
|
||||||
|
## Entity files
|
||||||
|
|
||||||
|
One file per entity (`asset.go`, `vendor.go`, etc.), plus optional companion `_filter.go` and `_order_field.go` files when needed. No codegen — everything is hand-written.
|
||||||
|
|
||||||
|
Entity structs do **not** have a `TenantID` field — the `tenant_id` column is provided by the `Scoper` (via `scope.GetTenantID()`) at query time, not stored on the Go struct.
|
||||||
|
|
||||||
|
## SQL query pattern
|
||||||
|
|
||||||
|
Every query uses raw SQL with `pgx.StrictNamedArgs` and scope injection via `fmt.Sprintf`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
q := `
|
||||||
|
SELECT id, name, created_at, updated_at
|
||||||
|
FROM assets
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND id = @asset_id
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"asset_id": assetID}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scoper interface
|
||||||
|
|
||||||
|
Every Load/Insert/Update/Delete method takes a `Scoper` parameter for tenant isolation:
|
||||||
|
- `Scope` — adds `tenant_id = @tenant_id` WHERE clause
|
||||||
|
- `NoScope` — returns `TRUE` (for cross-tenant operations)
|
||||||
|
|
||||||
|
Insert uses `scope.GetTenantID()` for the tenant_id value.
|
||||||
|
|
||||||
|
## Method patterns
|
||||||
|
|
||||||
|
| Method | Receiver | Returns | Notes |
|
||||||
|
|--------|----------|---------|-------|
|
||||||
|
| `LoadByID` | `*Entity` | `error` | Assigns into receiver via `*e = entity` |
|
||||||
|
| `LoadAllBy*` | `*Entities` (slice type) | `error` | Paginated with `page.Cursor[OrderField]` |
|
||||||
|
| `CountBy*` | `*Entities` | `(int, error)` | Uses `COUNT(id)` |
|
||||||
|
| `Insert` | `*Entity` | `error` | Uses `scope.GetTenantID()` for tenant_id |
|
||||||
|
| `Update` | `*Entity` | `error` | Uses `RETURNING` to reassign receiver |
|
||||||
|
| `Delete` | `*Entity` | `error` | — |
|
||||||
|
| `CursorKey` | `*Entity` | `page.CursorKey` | Switch on OrderField, panic on unknown |
|
||||||
|
| `AuthorizationAttributes` | `*Entity` | `(map[string]string, error)` | Returns org/tenant IDs for authz |
|
||||||
|
|
||||||
|
## Row collection
|
||||||
|
|
||||||
|
- Single row: `pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[T])`
|
||||||
|
- Multiple rows: `pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[T])`
|
||||||
|
- Check `pgx.ErrNoRows` → return `ErrResourceNotFound`
|
||||||
|
|
||||||
|
## Sentinel errors
|
||||||
|
|
||||||
|
Defined in `errors.go`:
|
||||||
|
- `ErrResourceNotFound` — row not found (`pgx.ErrNoRows`)
|
||||||
|
- `ErrResourceAlreadyExists` — unique constraint violation
|
||||||
|
- `ErrResourceInUse` — foreign key constraint prevents deletion
|
||||||
|
|
||||||
|
## Filter pattern
|
||||||
|
|
||||||
|
Double pointer fields: `nil` = no filter, `*nil` = IS NULL, `*val` = equals.
|
||||||
|
|
||||||
|
`SQLFragment()` must return a **static** SQL string (no conditional string building) so the prepared statement is always the same. Use `CASE WHEN` in SQL to handle optional filters.
|
||||||
|
|
||||||
|
`SQLArguments()` returns `pgx.StrictNamedArgs` — **every key referenced in the SQL must be set in every code path** (use `nil` for inactive filters). `StrictNamedArgs` rejects missing keys at runtime.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"show_on_trust_center": nil,
|
||||||
|
"has_snapshot_filter": false,
|
||||||
|
"filter_snapshot_id": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.showOnTrustCenter != nil {
|
||||||
|
args["show_on_trust_center"] = *f.showOnTrustCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.snapshotID != nil {
|
||||||
|
args["has_snapshot_filter"] = true
|
||||||
|
if *f.snapshotID != nil {
|
||||||
|
args["filter_snapshot_id"] = **f.snapshotID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *VendorFilter) SQLFragment() string {
|
||||||
|
return `
|
||||||
|
(
|
||||||
|
CASE
|
||||||
|
WHEN @show_on_trust_center::boolean IS NOT NULL THEN
|
||||||
|
show_on_trust_center = @show_on_trust_center::boolean
|
||||||
|
ELSE TRUE
|
||||||
|
END
|
||||||
|
AND
|
||||||
|
CASE
|
||||||
|
WHEN @has_snapshot_filter::boolean = false THEN TRUE
|
||||||
|
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
|
||||||
|
snapshot_id = @filter_snapshot_id::text
|
||||||
|
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
|
||||||
|
snapshot_id IS NULL
|
||||||
|
ELSE TRUE
|
||||||
|
END
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## OrderField pattern
|
||||||
|
|
||||||
|
OrderField types must validate their value via `IsValid()` and implement text marshalling:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type InvitationOrderField string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p InvitationOrderField) Column() string {
|
||||||
|
switch p {
|
||||||
|
case InvitationOrderFieldCreatedAt:
|
||||||
|
return "created_at"
|
||||||
|
}
|
||||||
|
panic(fmt.Sprintf("unsupported order by: %s", p))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e InvitationOrderField) IsValid() bool {
|
||||||
|
switch e {
|
||||||
|
case InvitationOrderFieldCreatedAt:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e InvitationOrderField) String() string { return string(e) }
|
||||||
|
|
||||||
|
func (e *InvitationOrderField) UnmarshalText(text []byte) error {
|
||||||
|
*e = InvitationOrderField(text)
|
||||||
|
if !e.IsValid() {
|
||||||
|
return fmt.Errorf("%s is not a valid InvitationOrderField", string(text))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e InvitationOrderField) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(e.String()), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Argument merging
|
||||||
|
|
||||||
|
Always use `maps.Copy` to combine args from scope, filter, and cursor. Never manually merge.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Pure SQL files in `pkg/coredata/migrations/` with timestamp names: `YYYYMMDDTHHMMSSZ.sql`.
|
||||||
47
pkg/server/api/console/v1/CLAUDE.md
Normal file
47
pkg/server/api/console/v1/CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# pkg/server/api/console/v1
|
||||||
|
|
||||||
|
GraphQL API using `gqlgen`. Schema-first approach.
|
||||||
|
|
||||||
|
## Generated vs hand-written
|
||||||
|
|
||||||
|
| File | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `schema.graphql` | Hand-written | GraphQL schema definition |
|
||||||
|
| `gqlgen.yaml` | Hand-written | Codegen config |
|
||||||
|
| `resolver.go` | Hand-written | Root `Resolver` struct and `NewMux` |
|
||||||
|
| `graphql_handler.go` | Hand-written | Handler setup |
|
||||||
|
| `v1_resolver.go` | Generated stubs | Resolver method implementations (edit the bodies) |
|
||||||
|
| `schema/schema.go` | **Generated — DO NOT EDIT** | Executable schema |
|
||||||
|
| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions |
|
||||||
|
|
||||||
|
## Important rules
|
||||||
|
|
||||||
|
- **Never edit generated files** (`schema/schema.go`, `types/types.go`). Only edit `schema.graphql` and resolver bodies.
|
||||||
|
- **After any change to `schema.graphql`**, always run codegen:
|
||||||
|
|
||||||
|
```
|
||||||
|
go generate ./pkg/server/api/console/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resolver pattern
|
||||||
|
|
||||||
|
Every resolver method follows this sequence:
|
||||||
|
|
||||||
|
1. **Authorize** — `r.authorize(ctx, obj.ID, probo.ActionXxxGet)`
|
||||||
|
2. **Get service** — `prb := r.ProboService(ctx, tenantID)`
|
||||||
|
3. **Call service** — `result, err := prb.Foo.Bar(ctx, ...)`
|
||||||
|
4. **Handle error** — wrap or panic on unexpected errors
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
Relay cursor pattern:
|
||||||
|
- `page.Cursor[OrderField]` for cursor handling
|
||||||
|
- Connection types (`*Connection`) with `ParentID`, `Resolver`, `Filter` fields
|
||||||
|
|
||||||
|
## Custom scalars
|
||||||
|
|
||||||
|
`ID`, `Datetime`, `CursorKey`, `Duration`, `BigInt`, `EmailAddr` — mapped in `gqlgen.yaml`.
|
||||||
|
|
||||||
|
## Authentication middleware
|
||||||
|
|
||||||
|
`NewMux()` chains: session → API key → identity presence middlewares.
|
||||||
80
pkg/server/api/mcp/v1/CLAUDE.md
Normal file
80
pkg/server/api/mcp/v1/CLAUDE.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# pkg/server/api/mcp/v1
|
||||||
|
|
||||||
|
MCP (Model Context Protocol) API. Schema-first approach using `mcpgen`.
|
||||||
|
|
||||||
|
## Generated vs hand-written
|
||||||
|
|
||||||
|
| File | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `specification.yaml` | Hand-written | Tool definitions, input/output schemas |
|
||||||
|
| `mcpgen.yaml` | Hand-written | Codegen config |
|
||||||
|
| `resolver.go` | Hand-written | Resolver struct, `MustAuthorize()`, helpers |
|
||||||
|
| `v1_handler.go` | Hand-written | `NewMux()`, MCP server setup |
|
||||||
|
| `middleware.go` | Hand-written | API key authentication |
|
||||||
|
| `helpers.go` | Hand-written | Pagination helpers |
|
||||||
|
| `schema.resolvers.go` | **Generated (preserved)** | Tool implementations — edit the bodies |
|
||||||
|
| `server/server.go` | **Generated — DO NOT EDIT** | Tool registration, `ResolverInterface` |
|
||||||
|
| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions and JSON schemas |
|
||||||
|
| `types/*.go` (other) | Hand-written | Type conversion helpers (`NewVendor`, etc.) |
|
||||||
|
|
||||||
|
## Important rules
|
||||||
|
|
||||||
|
- **Never edit generated files** (`server/server.go`, `types/types.go`). Only edit `specification.yaml`, resolver bodies, and hand-written helpers.
|
||||||
|
- **After any change to `specification.yaml`**, always run codegen:
|
||||||
|
|
||||||
|
```
|
||||||
|
go generate ./pkg/server/api/mcp/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads `specification.yaml` and generates server, types, and resolver stubs.
|
||||||
|
|
||||||
|
## Adding a new tool
|
||||||
|
|
||||||
|
1. Define the tool in `specification.yaml` under `tools:` with name, description, hints, inputSchema, outputSchema
|
||||||
|
2. Define input/output schemas under `components/schemas/`
|
||||||
|
3. Run `go generate ./pkg/server/api/mcp/v1`
|
||||||
|
4. Implement the tool body in `schema.resolvers.go`
|
||||||
|
5. Add type conversion helpers in `types/` if needed
|
||||||
|
|
||||||
|
## Tool definition format
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tools:
|
||||||
|
- name: listVendors
|
||||||
|
description: List all vendors for the organization
|
||||||
|
hints:
|
||||||
|
readonly: true
|
||||||
|
idempotent: true
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/ListVendorsInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/ListVendorsOutput"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resolver pattern
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Resolver) ListVendorsTool(ctx context.Context, input types.ListVendorsInput) (*types.ListVendorsOutput, error) {
|
||||||
|
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
|
||||||
|
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||||
|
// ... service call, type conversion
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `MustAuthorize()` panics on auth failure — caught by MCP recovery middleware
|
||||||
|
- Type conversion via `types.New*()` helpers
|
||||||
|
|
||||||
|
## Custom type mappings
|
||||||
|
|
||||||
|
In `specification.yaml`, map Go types with `go.probo.inc/mcpgen/type`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OrderDirection:
|
||||||
|
type: string
|
||||||
|
enum: [ASC, DESC]
|
||||||
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/page.OrderDirection
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
API key auth via `authn.NewAPIKeyMiddleware`. Mounted at `/mcp/v1`.
|
||||||
Reference in New Issue
Block a user