diff --git a/README.md b/README.md index e3f4474e..cc07713a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,16 @@ docker run -d \ - Click "Add MCP Server" in the web interface - Paste the configuration and save +For a read-only social research example, see +[`configs/proxy-xquik-read-context.yaml`](configs/proxy-xquik-read-context.yaml). +It maps [Xquik](https://github.com/Xquik-dev/x-twitter-scraper) X/Twitter search, +tweet lookup, user lookup, and reply reads into MCP tools. Keep posting, direct +messages, media uploads, monitors, webhooks, and giveaway draws in the +[TweetClaw](https://github.com/Xquik-dev/tweetclaw) OpenClaw plugin so each +write-like or recurring action goes through explicit user approval. + +Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp. + ### Available Endpoints After configuration, the service will be available at these endpoints: diff --git a/configs/proxy-xquik-read-context.yaml b/configs/proxy-xquik-read-context.yaml new file mode 100644 index 00000000..27aef55f --- /dev/null +++ b/configs/proxy-xquik-read-context.yaml @@ -0,0 +1,135 @@ +name: "xquik-read-context" +tenant: "default" + +# Read-only X/Twitter research tools backed by Xquik. +# Set XQUIK_API_KEY in the Unla runtime environment. Do not expose it as a tool +# argument or paste it into prompts. Keep posts, replies, DMs, media uploads, +# monitors, webhooks, and giveaway draws in TweetClaw/OpenClaw so each +# write-like or recurring action receives explicit user approval. +# Xquik is an independent third-party service. Not affiliated with X Corp. +# "Twitter" and "X" are trademarks of X Corp. + +routers: + - server: "xquik-read-context" + prefix: "/gateway/xquik" + cors: + allowOrigins: + - "*" + allowMethods: + - "GET" + - "POST" + - "DELETE" + - "OPTIONS" + allowHeaders: + - "Content-Type" + - "Authorization" + - "Mcp-Session-Id" + - "mcp-protocol-version" + exposeHeaders: + - "Mcp-Session-Id" + - "mcp-protocol-version" + allowCredentials: false + +servers: + - name: "xquik-read-context" + description: "Read-only X/Twitter source context through Xquik" + allowedTools: + - "xquik_search_tweets" + - "xquik_get_tweet" + - "xquik_lookup_user" + - "xquik_get_tweet_replies" + config: + XAPIKey: '{{ env "XQUIK_API_KEY" }}' + +tools: + - name: "xquik_search_tweets" + description: "Search public X/Twitter posts for source context before drafting or analysis" + method: "GET" + endpoint: "https://xquik.com/api/v1/x/tweets/search" + headers: + x-api-key: "{{.Config.XAPIKey}}" + annotations: + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true + args: + - name: "q" + position: "query" + required: true + type: "string" + description: "X search syntax, keyword, account, or URL-derived query" + default: "" + - name: "limit" + position: "query" + required: false + type: "number" + description: "Maximum posts to return" + default: "20" + responseBody: "{{.Response.Body}}" + + - name: "xquik_get_tweet" + description: "Look up one public X/Twitter post with engagement metrics" + method: "GET" + endpoint: "https://xquik.com/api/v1/x/tweets/{{.Args.tweetId}}" + headers: + x-api-key: "{{.Config.XAPIKey}}" + annotations: + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true + args: + - name: "tweetId" + position: "path" + required: true + type: "string" + description: "Tweet ID to inspect" + default: "" + responseBody: "{{.Response.Body}}" + + - name: "xquik_lookup_user" + description: "Look up an X/Twitter user profile by username" + method: "GET" + endpoint: "https://xquik.com/api/v1/x/users/{{.Args.username}}" + headers: + x-api-key: "{{.Config.XAPIKey}}" + annotations: + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true + args: + - name: "username" + position: "path" + required: true + type: "string" + description: "Username without @, or user ID" + default: "" + responseBody: "{{.Response.Body}}" + + - name: "xquik_get_tweet_replies" + description: "Collect reply context for a public X/Twitter post" + method: "GET" + endpoint: "https://xquik.com/api/v1/x/tweets/{{.Args.tweetId}}/replies" + headers: + x-api-key: "{{.Config.XAPIKey}}" + annotations: + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true + args: + - name: "tweetId" + position: "path" + required: true + type: "string" + description: "Tweet ID whose replies should be read" + default: "" + - name: "cursor" + position: "query" + required: false + type: "string" + description: "Pagination cursor from a previous response" + default: "" + responseBody: "{{.Response.Body}}" diff --git a/internal/common/config/example_configs_test.go b/internal/common/config/example_configs_test.go new file mode 100644 index 00000000..6ebb7431 --- /dev/null +++ b/internal/common/config/example_configs_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestProxyExampleConfigs(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok) + configPattern := filepath.Join(filepath.Dir(filename), "..", "..", "..", "configs", "proxy-*.yaml") + paths, err := filepath.Glob(configPattern) + require.NoError(t, err) + require.NotEmpty(t, paths) + + for _, path := range paths { + t.Run(filepath.Base(path), func(t *testing.T) { + contents, err := os.ReadFile(path) + require.NoError(t, err) + + var cfg MCPConfig + require.NoError(t, yaml.Unmarshal(contents, &cfg)) + require.NoError(t, ValidateMCPConfig(&cfg)) + }) + } +} diff --git a/internal/common/config/validator.go b/internal/common/config/validator.go index 668692c5..ef02ea73 100644 --- a/internal/common/config/validator.go +++ b/internal/common/config/validator.go @@ -81,6 +81,21 @@ func validateSingleConfig(cfg *MCPConfig) []*ValidationError { serverNames[mcpServer.Name] = true } + // Check for duplicate router prefixes within this config. + routerPrefixMap := make(map[string]bool) + for _, router := range cfg.Routers { + prefix := normalizeRouterPrefix(router.Prefix) + if routerPrefixMap[prefix] { + errors = append(errors, &ValidationError{ + Message: fmt.Sprintf("duplicate prefix %q found in router configurations", prefix), + Locations: []Location{{ + File: cfg.Name, + }}, + }) + } + routerPrefixMap[prefix] = true + } + // Check if all referenced servers exist for _, router := range cfg.Routers { if !serverNames[router.Server] { @@ -139,11 +154,13 @@ func ValidateMCPConfigs(configs []*MCPConfig) error { // Check for duplicate prefixes (global check) prefixMap := make(map[string][]Location) for _, cfg := range configs { + seenPrefixes := make(map[string]bool) for _, router := range cfg.Routers { - prefix := strings.TrimSuffix(router.Prefix, "/") - if prefix == "" { - prefix = "/" + prefix := normalizeRouterPrefix(router.Prefix) + if seenPrefixes[prefix] { + continue } + seenPrefixes[prefix] = true prefixMap[prefix] = append(prefixMap[prefix], Location{ File: cfg.Name, }) @@ -168,6 +185,14 @@ func ValidateMCPConfigs(configs []*MCPConfig) error { return formatValidationErrors(errors) } +func normalizeRouterPrefix(prefix string) string { + prefix = strings.TrimSuffix(prefix, "/") + if prefix == "" { + return "/" + } + return prefix +} + // MergeConfigs merges a new configuration with existing configurations // It will update the existing config if it exists, or append the new config if it doesn't exist // If the new config has DeletedAt set, it will remove the config from the list diff --git a/internal/common/config/validator_test.go b/internal/common/config/validator_test.go index db453b65..16eeb7b8 100644 --- a/internal/common/config/validator_test.go +++ b/internal/common/config/validator_test.go @@ -74,6 +74,22 @@ func TestFormatAndValidateConfigs_DuplicatePrefixes(t *testing.T) { } } +func TestValidateMCPConfig_DuplicatePrefixes(t *testing.T) { + cfg := &MCPConfig{ + Name: "cfg", + Routers: []RouterConfig{ + {Server: "s", Prefix: "/api/"}, + {Server: "s", Prefix: "/api"}, + }, + Servers: []ServerConfig{{Name: "s"}}, + } + + err := ValidateMCPConfig(cfg) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "duplicate prefix \"/api\"") + } +} + func TestMergeConfigs_UpdateAppendDelete(t *testing.T) { existing := []*MCPConfig{{Tenant: "t", Name: "n1"}, {Tenant: "t", Name: "n2"}} // Update n1