Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
135 changes: 135 additions & 0 deletions configs/proxy-xquik-read-context.yaml
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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}}"
31 changes: 31 additions & 0 deletions internal/common/config/example_configs_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
31 changes: 28 additions & 3 deletions internal/common/config/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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,
})
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/common/config/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down