AI-837: connector definitions API - #727
Conversation
ListConnectorDefinitions takes a filter: a case-insensitive text match on the newest revision's id, name, category and description, a cursor position and a limit (25 by default, 200 at most), and returns one row past the limit so a caller can tell the page is not the last. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
listConnectors, getConnector and createConnector, declared with Huma and server-side only. A definition shows the non-secret part of its manifest: schemes, inputs, scopes and the client policy. Endpoints, vars, capture and identity rules, refresh and rate limits, sources, hooks and client.env are withheld. createConnector stores a custom MCP server: an id starting with custom_, a public https endpoint checked by egress, and schemes the deployment's connector registry has, which is injected through Options.Connectors. Go SDK regenerated; the sdk skill notes what the other SDKs need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kanat
left a comment
There was a problem hiding this comment.
One nit, inline. Otherwise this is an approve: the catalog, the custom MCP checks, and the paging held up.
TestConnectorsSuite passed on a fresh database. The js failure is the generated types lagging the spec; generated.sh reports that drift as inherited from accelerate.
|
|
||
| read := s.get(id) | ||
| // Postgres keeps microseconds, and the answer to the create is what was written. | ||
| s.WithinDuration(created.CreatedAt, read.CreatedAt, time.Millisecond) |
There was a problem hiding this comment.
createConnector returns the time.Now() that saveRevision stamps at acceleration/internal/store/connectors.go:242. getConnector returns the microseconds Postgres stored. This allows the two to differ by up to a millisecond.
RETURNING on that insert would make the create response the stored row. The insert itself is outside this diff.
There was a problem hiding this comment.
Fixed in 6607b95: the insert in saveRevision now has .Returning("created_at"), so the create answers with the value Postgres stored (microseconds), the same one a later read returns. The test now compares the create answer and the read with s.Equal, without the millisecond tolerance.
Mutation check: without Returning the test fails on Linux (the answer to the create is the stored row) and passes with it. On macOS it passes either way, because time.Now() there only has microsecond resolution (Nanosecond() % 1000 is always 0 on darwin, non-zero on Linux in golang:1.27-bookworm), so CI on Linux is what catches a regression.
…ored Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kanat
left a comment
There was a problem hiding this comment.
Reviewed at 6607b953. CI: js fails, the rest pass. The js failure is the generated types lagging the spec, and generated.sh reports that drift (sdks/js/src/generated/api.ts) as inherited from accelerate at 96a5904, so it is not from this PR.
I ran go test -count=1 -tags integration ./internal/api -run 'TestConnectorsSuite|TestPostureSuite' and ./internal/store -run TestStoreSuite against a fresh pr727_test database. Both pass, and all 24 ConnectorsSuite tests ran.
Two inline comments: one Question about how the created_at fix is guarded, one Nit on search.
Nit (description): the «Open» bullet saying created_at comes back to the nanosecond and that "the fix belongs in store.saveRevision, which this PR does not touch" is stale. 6607b95 adds .Returning("created_at") there, and TestACustomMCPConnectorIsStoredAndReadBack now uses s.Equal.
Verdict: approve once it leaves draft (I'm posting this as a comment because it is your own PR). Neither finding blocks.
Checked and sound:
- Paging: the cursor predicate
(customer_id <> '', id) > (c, id)sits inside theDISTINCT ONand matches the outerORDER BY cd.customer_id, cd.id. It is safe because only''and the caller's id are in scope. - Text search runs on the newest revision only, and
%,_and\are escaped. connectorDefinitionOfcopies fields one by one.- A whitespace-only
nameis trimmed and then refused byManifest.Validate(name: is empty). schemes: nullis refused byValidate(schemes: is empty).- None of the three operations is
x-client-accessible. - The sdk skill note is at the bottom.
| s.Equal([]ConnectorClientOwner{"dcr", "customer"}, created.Client.Policy) | ||
| s.Equal(ConnectorClientAuthMethod("client_secret_basic"), created.Client.AuthMethod) | ||
|
|
||
| s.Equal(created, s.get(id), "the answer to the create is the stored row") |
There was a problem hiding this comment.
Question: the earlier thread says CI on Linux is what catches a regression of the Returning("created_at") fix. The go job in .github/workflows/ci.yml:82 runs go test ./... without -tags integration, and this file is //go:build integration. So no CI job runs this assertion, and on macOS it passes with or without the fix. Today the regression is caught only when someone runs the integration suite on Linux by hand. Does that guard count as enough, or should the PR description say so instead of naming CI?
There was a problem hiding this comment.
Right: connectors_test.go is //go:build integration and the go job runs go test ./... without the tag (.github/workflows/ci.yml:82), so no CI job runs this assertion, and on macOS it passes with or without the fix. My earlier reply was wrong to name CI. The PR description now says the guard is a manual integration run on Linux (section «After review»). Making it a CI guard means running the integration suites in CI, which is outside this PR.
| if filter.Text != "" { | ||
| // Backslash is ILIKE's default escape, so the caller's % and _ match themselves. | ||
| escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(filter.Text) | ||
| query = query.Where("concat_ws(' ', cd.id, cd.name, cd.category, cd.description) ILIKE ?", "%"+escaped+"%") |
There was a problem hiding this comment.
Nit: because the fields are joined with a space, a query can match across the boundary between two fields. q=k s matches slack, whose joined text is slack Slack …. I checked it on the seeded built-ins:
SELECT id, concat_ws(' ', id, name, category, description) ILIKE '%k s%'
FROM (SELECT DISTINCT ON (customer_id, id) * FROM connector_definitions
WHERE customer_id = '' ORDER BY customer_id, id, revision DESC) cd;
-- linear|f
-- slack|tIf matching only within one field is what's intended, cd.id ILIKE ?0 OR cd.name ILIKE ?0 OR … gives that. If it doesn't matter for a picker, ignore this.
There was a problem hiding this comment.
Fixed in e1d93fc: each field is matched on its own, (cd.id ILIKE ?0 OR cd.name ILIKE ?0 OR cd.category ILIKE ?0 OR cd.description ILIKE ?0). TestTheSearchMatchesTheIdNameCategoryOrDescriptionIgnoringCase now also asserts CRM Ticketing (end of the name, start of the category) matches nothing; with the concat_ws form back it fails.
kanat
left a comment
There was a problem hiding this comment.
Reviewed at 6607b953 in ../Vision-Agents-pr-727. CI: js fails, the rest pass. generated.sh reports the sdks/js/src/generated/api.ts drift as inherited from accelerate at 96a5904, so it is not from this PR.
Ran on a fresh database: go test -count=1 -tags integration ./internal/api -run 'TestConnectorsSuite|TestPostureSuite' passes, and so does ./internal/store -run TestStoreSuite. On the first run against a database that did not exist yet, TestConnectorsSuite failed in testDatabase (router_suite_test.go:175): two parallel suites raced on CREATE DATABASE (duplicate key value violates unique constraint "pg_database_datname_index"). That helper comes from #698 on the base, not from this PR, and a rerun passes.
Three inline comments: one Should fix (the egress error tells the caller whether an internal hostname exists), one Question, one Nit.
The two unresolved threads still apply at this head:
- The CI Question on
connectors_test.go:102:.github/workflows/ci.yml:82runsgo test ./...with no-tags integration, so CI does not run this file. - The
concat_wsNit onstore/connectors.go:184.
The description's «Open» bullet saying that created_at comes back to the nanosecond, and that the fix "belongs in store.saveRevision, which this PR does not touch", is still stale. 6607b95 made that fix.
Verdict: approve once it leaves draft. Nothing here blocks: no scheme is registered on accelerate, so no custom connector can be created or connected yet. The egress message is worth fixing before T9 registers one.
Checked and sound:
- None of the three operations is
x-client-accessible. connectorDefinitionOfcopies fields one by one.- The custom endpoint is checked again at dial time:
egress.NewClient→dialPublic,egress/public.go:248. A DNS rebind after create does not get past it. - The cursor predicate sits inside the
DISTINCT ONand matches the outer order. - A
{placeholder}in a custom endpoint is refused bycheckTemplate, because a custom definition declares no inputs. - The sdk skill note is at the bottom.
| } | ||
| // Last, since it resolves the host: the checks above cost nothing. | ||
| if err := egress.ValidatePublicHTTPSURL(ctx, sent.Endpoint); err != nil { | ||
| return core.Manifest{}, fmt.Errorf("endpoint: %w", err) |
There was a problem hiding this comment.
Should fix: this passes the egress error to the caller word for word. The caller can tell a name that does not resolve from one that resolves to a private address:
egress/public.go:296:egress: endpoint host could not be resolvedegress/public.go:300:egress: endpoint host resolved to a non-public address
On the hosted router, any app with a server-side token can send https://<guess>/mcp and learn which internal names the router's resolver knows, such as cluster service names. This handler is the only caller of ValidatePublicHTTPSURL (grep -rn ValidatePublicHTTPSURL internal), so this PR is the first to put that difference on the API.
One message for both resolution failures closes it, for example endpoint: host does not resolve to a public address. The syntax errors can keep their wording. Resolution time still differs between the two cases, but the text is the cheap part to close.
Not run: no test resolves a name, as the description says.
There was a problem hiding this comment.
Fixed in e1d93fc: every egress refusal now answers endpoint must be a public https URL without userinfo, query or fragment, with a comment on why. TestAnEndpointThatDoesNotResolveIsRefusedLikeAPrivateOne posts https://router-probe.invalid/mcp (.invalid never resolves, RFC 6761 §6.4) and https://10.0.0.7/mcp and requires the two error bodies to be equal; passing the egress error through again makes it fail.
| } | ||
| } | ||
| var client core.ClientPolicy | ||
| if sent.Client != nil { |
There was a problem hiding this comment.
Question: when client is omitted, the definition is stored with an empty client.policy, which ConnectorClient.Policy documents as "Empty when the connector needs none". The only scheme a custom definition can name is oauth2_code, and an OAuth code exchange needs a client. If T9's oauth2_code cannot connect without a client owner, createConnector answers 200 for a definition no connection can use. This can't be settled until T9 says what oauth2_code needs. The fix would be either Manifest.Validate requiring a policy for a scheme that needs a client, or this handler refusing an empty client while oauth2_code is the only scheme.
There was a problem hiding this comment.
Settled now that #730 is merged: pickClient in internal/connectors/schemes/oauth2code/client.go tries only the owners the policy names and returns ErrNoClient when it names none. Fixed in e1d93fc: a custom definition naming oauth2_code without client.policy is a 400 (client.policy is required with oauth2_code …). Test: TestAnOAuthConnectorWithoutAClientPolicyIsRefused; it fails with the check removed. The test helper now sends policy: [dcr] by default.
| Description string `json:"description,omitempty" maxLength:"1000"` | ||
| Endpoint string `json:"endpoint" maxLength:"2048" doc:"The MCP server, over Streamable HTTP: a public https URL without userinfo, query or fragment. An address on a private network, loopback or link-local is refused."` | ||
| Schemes []string `json:"schemes" minItems:"1" doc:"How a connection may authenticate. Each must be a scheme this deployment has."` | ||
| Scopes []string `json:"scopes,omitempty" maxItems:"100" pattern:"^[!#-\\[\\]-~]+$" patternDescription:"an RFC 6749 scope token" doc:"The scopes a consent asks for, each an RFC 6749 scope token."` |
There was a problem hiding this comment.
Nit: a repeated scope, such as ["crm.read", "crm.read"], is stored as sent, and so is a repeated client.policy owner such as ["dcr", "dcr"] (line 65). Manifest.Validate refuses a repeated scheme ("%q is listed twice", core/manifest.go) but checks neither of these. uniqueItems:"true" on Scopes and on ConnectorClient.Policy would refuse them in the schema, like the other rules here. Not run.
There was a problem hiding this comment.
Fixed in e1d93fc: uniqueItems:"true" on CustomConnectorRequest.Scopes and ConnectorClient.Policy; openapi.yaml regenerated (Go SDK unchanged). TestARepeatedScopeOrClientOwnerIsRefused sends ["crm.read","crm.read"] and ["dcr","dcr"] and expects 400 for each; without the tags it fails.
- The endpoint check answers the same for a name that does not resolve and one that resolves to a private address, so a caller cannot probe the router's resolver. - A custom definition naming oauth2_code must name a client owner: the scheme fails with ErrNoClient on an empty policy. - Scopes and client owners may not repeat. - The search matches within one field, not across two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(store): page and search connector definitions ListConnectorDefinitions takes a filter: a case-insensitive text match on the newest revision's id, name, category and description, a cursor position and a limit (25 by default, 200 at most), and returns one row past the limit so a caller can tell the page is not the last. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(api): connector definitions endpoints (AI-837) listConnectors, getConnector and createConnector, declared with Huma and server-side only. A definition shows the non-secret part of its manifest: schemes, inputs, scopes and the client policy. Endpoints, vars, capture and identity rules, refresh and rate limits, sources, hooks and client.env are withheld. createConnector stores a custom MCP server: an id starting with custom_, a public https endpoint checked by egress, and schemes the deployment's connector registry has, which is injected through Options.Connectors. Go SDK regenerated; the sdk skill notes what the other SDKs need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): answer a connector create with the created_at Postgres stored Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): one answer for a refused endpoint, a client for oauth2_code - The endpoint check answers the same for a name that does not resolve and one that resolves to a private address, so a caller cannot probe the router's resolver. - A custom definition naming oauth2_code must name a client owner: the scheme fails with ErrNoClient on an empty policy. - Scopes and client owners may not repeat. - The search matches within one field, not across two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
T15 of AI-816: AI-837. The connector catalog over the
connector_definitionstable from #716: list and search the built-ins and the app's own, read one, add a custom MCP server.How it works
flowchart LR B[App backend] -->|GET /v1/agents/connectors q limit cursor| L[listConnectors] B -->|GET /v1/agents/connectors/id| G[getConnector] B -->|POST /v1/agents/connectors| C[createConnector] L --> S[(connector_definitions newest revision per id)] G --> S C --> V{customManifest} V -->|"id not custom_, scheme not registered, operator client, bad scope, unknown field"| E["400 error"] V -->|egress.ValidatePublicHTTPSURL refuses| E V -->|ok| W[store.CreateConnectorDefinition next revision or same] W --> S S --> O[connectorDefinitionOf non-secret fields only]flowchart TD R[POST body] --> P{"Huma schema: id matches ^custom_, name 1-120, scopes RFC 6749, no unknown field"} P -->|no| X[400] P -->|yes| Q{every scheme in Options.Connectors.Schemes} Q -->|no| X Q -->|yes| K{client.policy has operator} K -->|yes| X K -->|no| M{core.Manifest.Validate} M -->|no| X M -->|yes| N{"egress: public https, resolves to public IPs only"} N -->|no| X N -->|yes| Y["stored, 200"]Operations
/v1/agents/connectors(listConnectors)/v1/agents/connectors/{id}(getConnector)/v1/agents/connectors(createConnector)The posture is enforced by the existing middleware reading the spec.
TestPostureSuitecovers the three, and each also has its ownassertPosture(serverOnly, …)test.Exposed vs withheld
Built field by field in
connectorDefinitionOf(internal/api/connectors.go:334). A field added tocore.Manifestlater stays hidden until someone adds it there.id,revision,name,category,description,custom,created_atendpoints,varsunverified). Egress refuses a query string, so the path is the only place left for oneschemesauthorize_params,token_paramsinputs(name,enum,pattern,default)capture,identityscopes(the list)scopes.separator,send_on_refresh,step_up_unionclient.policy,client.auth_method,client.algclient.envrefresh,rate_limit,sources,hooksTestWhatTheRouterReadsToConnectIsNeverShownreads Slack's built-in, whose YAML has all of these. It checks that none of the withheld keys is in the JSON, thatclienthas noenv, and thatSLACK,mcp.slack.com,slack.com/apiand$.team.idappear nowhere in the body.TestACustomConnectorsEndpointIsNeverShownchecks the same for a custom connector's endpoint.Custom MCP definition
^custom_[a-z][a-z0-9_]{0,56}$, so it cannot shadow a built-in (built-ins never take the prefix,store.builtinManifests) → 400{"error": "validation failed: expected string to be custom_ then …"}connectors.go:119TestAnIdThatWouldShadowABuiltInIsRefused(also checks Slack is untouched)egress.ValidatePublicHTTPSURL,connectors.go:326TestAnEndpointOnAPrivateNetworkIsRefused(127.0.0.1, 10.0.0.7, 169.254.169.254, ::1),TestAnEndpointThatIsNotPlainHTTPSIsRefused. Only IP literals, so no test resolves a nameOptions.Connectors core.Registry(server.go:172),connectors.go:285TestASchemeThisDeploymentDoesNotHaveIsRefusedclient.policycannot beoperator(no operator client exists for an app's own server, and a custom definition cannot setclient.env)connectors.go:301TestAnOperatorClientIsRefusedForACustomConnectorhooks,endpoints,client.env) refusedadditionalProperties: falseTestAFieldTheRequestDoesNotHaveIsRefusedstore.saveRevision(#716)TestSendingTheSameConnectorAgainChangesNothingAndAChangeIsTheNextRevisioncustomer_id IN ('', ?)TestAnotherAppsCustomConnectorIsNeitherListedNorRead,TestAnotherAppMayUseTheSameCustomIdWithoutTouchingThisOneThe registry is a field of the server, not a global.
cmd/routeris not wired: no scheme exists onaccelerateyet (T9 addsoauth2_code). Until it is, everycreateConnectoris a 400 naming the scheme, while list and get work. The tests register a name-onlynamedScheme("oauth2_code")(stubs_test.go) through a newRouterSuite.connectorsfield.Paging
As the pagination skill says, the store change stays inside
ListConnectorDefinitions(store/connectors.go:164).{c: custom, id}. It holdscustomrather than the customer id, so it carries nothing about whose it was. Next page is(customer_id <> '', id) > (c, id).limit + 1rows forhas_more, no count. A bad cursor is a 400.qis a case-insensitive substring ofid name category description, as the prototype's catalog search did (internal/api/connectors.go:72oncodex/connector-support). It is matched on the newest revision only, in an outer query overDISTINCT ON, so an old name does not match.%,_and\are escaped.Tests:
TestPagingWalksTheWholeListWithoutRepeatingOrSkipping(limit 2 to the end equals one page),TestTheListIsTheBuiltInsThenTheAppsOwnEachById,TestTheSearchReadsTheNewestRevisionOnly,TestASearchForAWildcardMatchesOnlyItself,TestACursorThisListDidNotHandOutIsRefused.Values and their sources
name≤ 120,category≤ 80,description≤ 1000CreateConnectorDefinitionRequest(api/openapi.yamloncodex/connector-supportat cf62af0)^custom_[a-z][a-z0-9_]{0,56}$(64 max)customConnectorIDPattern(internal/api/connectors.go:55there) without-, whichcore.Manifest.Validaterefuses in an id^[!#-\[\]-~]+$scope-tokenalgRS256,PS256core.assertionAlgsq≤ 120unverifiedas the right bound for searchstore/sessions.go:16-19),unverifiedfor a catalogunverified: the prototype set noneunverified: well over Slack's 29 (providers/slack.yaml)Tests run
ROUTER_POSTGRES_DSN=…/<db>_test ROUTER_REDIS_ADDR=… go test -tags integration ./internal/api: all suites pass, including the 27 inConnectorsSuiteandTestPostureSuite/TestTheCommittedSpecIsRenderedFromTheOperations. The same command passes for./internal/store(TestStoreSuite) and./cmd/router(TestOpenStoreSuite).go vet ./...andgo test ./...inacceleration/pass.go generate .,go build ./...andgo vet ./...insdks/gopass.custom_pattern onidTestAnIdThatWouldShadowABuiltInIsRefused: 500, not 400 (the store refuses it as an unexpected error)envtoConnectorClientand copyclient.envTestWhatTheRouterReadsToConnectIsNeverShownTestAnotherAppsCustomConnectorIsNeitherListedNorReadAfter review
created_atof the create differed from the readsaveRevisioninsertsRETURNING created_at(6607b953)TestACustomMCPConnectorIsStoredAndReadBackcompares withs.Equal. It is//go:build integration, and CI'sgojob runsgo test ./...without the tag (.github/workflows/ci.yml:82), so no CI job runs it; on macOStime.Now()has microseconds only, so it passes there either way. The guard is a manual integration run on Linuxe1d93fce)TestAnEndpointThatDoesNotResolveIsRefusedLikeAPrivateOne(.invalid, RFC 6761 §6.4, vs10.0.0.7)oauth2_codedefinition without a client policy was storedpickClientinschemes/oauth2code/client.gofails withErrNoClienton an empty policyTestAnOAuthConnectorWithoutAClientPolicyIsRefuseduniqueItems:"true"on bothTestARepeatedScopeOrClientOwnerIsRefusedq=k s→slack)TestTheSearchMatchesTheIdNameCategoryOrDescriptionIgnoringCase(CRM Ticketingspans name and category)Open
jsCI job checksnpm run types -- --check, so it will report the spec as ahead, as it did on feat(conversation): ask a person before a tool call runs, on its ai_tool_call step #725. A note for the other SDKs is at the bottom of.claude/skills/sdk/SKILL.md.ListConnectors,GetConnector,CreateConnector), noclient.Connectors()resource methods yet.POSTanswers 200 both when it stores a new revision and when the newest already said the same. The store does not say which happened._testdatabase that a prototype-branch run had migrated fails:connector_definitionsalready exists there with the prototype's columns. A fresh_testdatabase passes.🤖 Generated with Claude Code