Skip to content

feat: import OpenAPI and GraphQL specs as MCP tools - #198

Open
daltoniam wants to merge 3 commits into
mainfrom
feat/spec-imports
Open

feat: import OpenAPI and GraphQL specs as MCP tools#198
daltoniam wants to merge 3 commits into
mainfrom
feat/spec-imports

Conversation

@daltoniam

Copy link
Copy Markdown
Owner

Summary

  • Adds a "bring your own spec" feature: paste an OpenAPI 3.x document or a GraphQL introspection result and its operations become searchable, executable MCP tools — no adapter code required.
  • New management page in the web UI (list / add / remove) with live status, plus live reload so imported tools appear without restarting the server.

How it works

  • New specimport/ package parses a spec into a runtime Integration (Load), and a Syncer reconciles the registry against config on startup and on every config change — it only ever touches integrations it registered, never built-ins.
  • Imports are stored in config under spec_imports and injected server-side credentials never reach the model.

Fixes bundled in

Two bugs discovered during live testing that made imported tools invisible to search/execute even though they showed as active in the UI:

  1. Config loading silently dropped imports on every read (they weren't carried through the defaults merge).
  2. The enabled-integration lookup used by search indexing and execute resolution didn't include imports.

Verification

Tested end-to-end against the live Swagger Petstore: search returns the imported tools and execute performs real upstream calls (valid IDs return data, invalid IDs return the upstream 404). Full CI passes (build, vet, race tests, lint).

💘 Generated with Crush

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 acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI/status checks all passing (build, test, lint, security). Clean PR — follows project conventions, comprehensive test coverage, no blocking issues found. LGTM.

@acmacalister acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/web_spec_imports.go Outdated
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 != "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"))
    ...
}

Comment thread web/web_spec_imports.go
}

// Validate by loading before persisting.
if _, err := specimport.Load(entry); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Registeralready 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.

Comment thread config/config.go
// 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
}

Comment thread specimport/integration.go Outdated
// 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/web_spec_imports.go Outdated
target := specimport.SanitizedName(entry.Name)
for i := range list {
if specimport.SanitizedName(list[i].Name) == target {
list[i] = entry

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 acmacalister left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/web_spec_imports.go
Endpoint: strings.TrimSpace(r.FormValue("endpoint")),
Enabled: true,
}
if creds := specImportCreds(r); len(creds) > 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread specimport/specimport.go
// 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants