feat: import OpenAPI and GraphQL specs as MCP tools - #198
Conversation
Let users bring their own API by pasting an OpenAPI 3.x document or a GraphQL introspection result. Each import becomes a live integration whose operations are exposed as searchable, executable MCP tools, with a management page in the web UI and live reload so new tools appear without a restart. Also fixes two issues that prevented imported tools from being reachable: config loading silently discarded imports on every read, and the enabled-integration lookup used by search indexing and execute resolution ignored imports entirely. Co-Authored-By: Crush <noreply@anthropic.com>
acmacalister
left a comment
There was a problem hiding this comment.
CI/status checks all passing (build, test, lint, security). Clean PR — follows project conventions, comprehensive test coverage, no blocking issues found. LGTM.
acmacalister
left a comment
There was a problem hiding this comment.
Solid feature — clean Syncer boundary, thorough parser/execute coverage, and the EnabledIntegrations/config-merge fixes are the right root-cause fixes. CI is green across build/test/lint/security. A few concrete gaps inline around auth_scheme, name collisions/dedupe, and dead IsWrite wiring.
| func specImportCreds(r *http.Request) mcp.Credentials { | ||
| creds := mcp.Credentials{} | ||
| for _, key := range []string{"api_key", "auth_header", "auth_scheme"} { | ||
| if v := strings.TrimSpace(r.FormValue(key)); v != "" { |
There was a problem hiding this comment.
The form copy says leave auth_scheme empty to send a raw key, but we only persist non-empty values here. An empty field never lands in the credentials map, so Configure takes the !hasScheme path and still defaults to Bearer.
Either stop advertising the empty-scheme case in the UI, or persist an explicit sentinel (e.g. always write the key when api_key is set, including auth_scheme: "") so hasScheme is true:
if apiKey := strings.TrimSpace(r.FormValue("api_key")); apiKey != "" {
creds["api_key"] = apiKey
// Always record scheme when a key is present so empty means "raw".
creds["auth_scheme"] = strings.TrimSpace(r.FormValue("auth_scheme"))
...
}| } | ||
|
|
||
| // Validate by loading before persisting. | ||
| if _, err := specimport.Load(entry); err != nil { |
There was a problem hiding this comment.
Load succeeding is necessary but not sufficient — registration can still fail when the sanitized name collides with a built-in (github, slack, web, …). Those are always in the registry at startup, so Sync hits Register → already registered, the entry stays in config as Error, and if that built-in is also enabled, EnabledIntegrations emits the name twice and search indexes the same tools twice.
Worth rejecting reserved/colliding names up front (against Registry.Names() or a fixed built-in set) before persisting, with a clear flash like name \"github\" is reserved by a built-in integration.
| // Spec imports are registered under their sanitized name and must be | ||
| // reported here so search indexing and execute resolution can find them — | ||
| // they live in a separate config list from built-in integrations. | ||
| for _, si := range m.cfg.SpecImports { |
There was a problem hiding this comment.
This appends every enabled import’s sanitized name with no dedupe. Two config entries that collapse to the same identifier (Demo API + demo-api), or an import whose name matches an enabled built-in, make EnabledIntegrations return duplicates. Downstream search/index paths iterate this list as-is, so tools get indexed twice.
A small seen-set (or skipping names already present from Integrations) would close that:
seen := make(map[string]struct{}, len(names))
for _, n := range names { seen[n] = struct{}{} }
for _, si := range m.cfg.SpecImports {
if !si.Enabled { continue }
n := specimport.SanitizedName(si.Name)
if _, ok := seen[n]; ok { continue }
seen[n] = struct{}{}
names = append(names, n)
}| // IsWrite reports whether a tool was classified as a mutating operation. | ||
| // The policy layer uses this to require approval for writes derived from the | ||
| // spec's semantics (HTTP verb / GraphQL operation type). | ||
| func (in *Integration) IsWrite(toolName mcp.ToolName) bool { |
There was a problem hiding this comment.
IsWrite is implemented and tested, but nothing outside this package calls it — there’s no WriteAware (or similar) port on mcp.Integration, and the server never type-asserts it. Right now the only write signal that reaches the model is the [write] description prefix.
Either wire this through a real optional interface the policy/search layer consults, or drop the method (and the “policy layer uses this” comments) so we don’t imply write gating that isn’t actually enforced.
| target := specimport.SanitizedName(entry.Name) | ||
| for i := range list { | ||
| if specimport.SanitizedName(list[i].Name) == target { | ||
| list[i] = entry |
There was a problem hiding this comment.
Get() hands back the live config pointer, and on a hit this assigns into that shared slice before SetSpecImports runs. Concurrent readers of SpecImports (or a racing save) can observe a half-updated entry with no lock held.
Safer to copy first, then mutate the copy:
out := make([]mcp.SpecImportConfig, len(list))
copy(out, list)
// replace or append on out
return out(removeSpecImport already avoids this via list[:0:0].)
Dedupe enabled integration names, reject reserved built-in names on import, persist empty auth_scheme as raw-key mode, copy on upsert, and drop the unused IsWrite method in favor of the [write] description tag. 💘 Generated with Crush Assisted-by: Crush:grok-4.5
acmacalister
left a comment
There was a problem hiding this comment.
CI is green across build/test/lint/security. Prior feedback looks addressed (auth_scheme sentinel, reserved names, EnabledIntegrations dedupe, upsert copy, IsWrite removal). Two small follow-ups inline — credential wipe on re-save, and docs that still imply write-approval gating.
| Endpoint: strings.TrimSpace(r.FormValue("endpoint")), | ||
| Enabled: true, | ||
| } | ||
| if creds := specImportCreds(r); len(creds) > 0 { |
There was a problem hiding this comment.
Re-saving an existing import with a blank API key wipes credentials. entry starts with a nil Credentials map, and we only assign when the form sends something — so an upsert of the same name replaces the stored key with nothing.
Worth merging with the previous entry when the form leaves auth fields empty, e.g. keep list[i].Credentials unless the form actually supplied new ones. Password inputs being empty on re-submit is the usual case here.
| // in tool definitions or results. | ||
| // - Operation semantics are preserved: read-only operations (HTTP GET / | ||
| // HEAD, GraphQL queries) are marked safe; writes (POST/PUT/PATCH/DELETE, | ||
| // GraphQL mutations) are marked destructive so the policy layer can |
There was a problem hiding this comment.
After dropping IsWrite, this package doc (and a few sibling comments in openapi.go / integration.go) still says writes are "marked destructive so the policy layer can gate them." In-tree there’s no write-approval / destructive-hint path — the only signal is the [write] description prefix.
Might be worth toning these down to match what’s actually enforced, so we don’t imply gating that isn’t wired yet.
Summary
How it works
specimport/package parses a spec into a runtimeIntegration(Load), and aSyncerreconciles the registry against config on startup and on every config change — it only ever touches integrations it registered, never built-ins.spec_importsand injected server-side credentials never reach the model.Fixes bundled in
Two bugs discovered during live testing that made imported tools invisible to
search/executeeven though they showed as active in the UI:Verification
Tested end-to-end against the live Swagger Petstore: search returns the imported tools and
executeperforms real upstream calls (valid IDs return data, invalid IDs return the upstream 404). Full CI passes (build, vet, race tests, lint).💘 Generated with Crush