Skip to content

Commit 4cd5c32

Browse files
xaionaro@dx.centerxaionaro@dx.center
authored andcommitted
feat(mcp): add generic call_android_api tool
1 parent 14e3fee commit 4cd5c32

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

mcp/server.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,4 @@ func (s *Server) Run(ctx context.Context) error {
5353
return s.mcp.Run(ctx, &gomcp.StdioTransport{})
5454
}
5555

56-
func (s *Server) registerGenericTool() {}
57-
func (s *Server) registerRawJNITool() {}
56+
func (s *Server) registerRawJNITool() {}

mcp/tools_generic.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
9+
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
10+
"google.golang.org/grpc"
11+
"google.golang.org/protobuf/encoding/protojson"
12+
"google.golang.org/protobuf/reflect/protoreflect"
13+
"google.golang.org/protobuf/reflect/protoregistry"
14+
"google.golang.org/protobuf/types/dynamicpb"
15+
)
16+
17+
type callAndroidAPIInput struct {
18+
Service string `json:"service" jsonschema:"Service key (e.g., 'battery/ManagerService'). Use jni://services resource to list available services."`
19+
Method string `json:"method" jsonschema:"RPC method name (e.g., 'GetIntProperty'). Use jni://services/{service} resource to list methods."`
20+
Params json.RawMessage `json:"params,omitempty" jsonschema:"JSON object with method parameters (e.g., {\"arg0\": 4})"`
21+
}
22+
23+
func (s *Server) registerGenericTool() {
24+
gomcp.AddTool(s.mcp, &gomcp.Tool{
25+
Name: "call_android_api",
26+
Description: "Invoke any Android system service method via gRPC. " +
27+
"Use the jni://services resource to discover available services and " +
28+
"jni://services/{service} to list methods. " +
29+
"Parameters are passed as a JSON object with positional keys (arg0, arg1, etc.).",
30+
Annotations: &gomcp.ToolAnnotations{
31+
Title: "Call Android API",
32+
},
33+
}, s.handleCallAndroidAPI)
34+
}
35+
36+
func (s *Server) handleCallAndroidAPI(
37+
ctx context.Context,
38+
req *gomcp.CallToolRequest,
39+
input callAndroidAPIInput,
40+
) (*gomcp.CallToolResult, any, error) {
41+
// Validate inputs.
42+
if input.Service == "" {
43+
return nil, nil, fmt.Errorf("service is required")
44+
}
45+
if input.Method == "" {
46+
return nil, nil, fmt.Errorf("method is required")
47+
}
48+
49+
// Convert service key "battery/ManagerService" to proto full service
50+
// name "battery.ManagerService".
51+
protoService := serviceKeyToProtoName(input.Service)
52+
53+
// Look up the service descriptor in the global proto registry.
54+
svcDesc, err := findServiceDescriptor(protoService)
55+
if err != nil {
56+
return nil, nil, fmt.Errorf("service %q: %w", input.Service, err)
57+
}
58+
59+
// Find the method descriptor.
60+
methodDesc := svcDesc.Methods().ByName(protoreflect.Name(input.Method))
61+
if methodDesc == nil {
62+
var available []string
63+
for i := 0; i < svcDesc.Methods().Len(); i++ {
64+
available = append(available, string(svcDesc.Methods().Get(i).Name()))
65+
}
66+
return nil, nil, fmt.Errorf(
67+
"method %q not found in service %q; available methods: %s",
68+
input.Method, input.Service, strings.Join(available, ", "),
69+
)
70+
}
71+
72+
// Build the request message from the user's JSON params.
73+
reqMsg := dynamicpb.NewMessage(methodDesc.Input())
74+
if len(input.Params) > 0 {
75+
if err := protojson.Unmarshal(input.Params, reqMsg); err != nil {
76+
return nil, nil, fmt.Errorf("unmarshal params into %s: %w", methodDesc.Input().FullName(), err)
77+
}
78+
}
79+
80+
// Build the gRPC full method path: /{package}.{Service}/{Method}
81+
fullMethod := fmt.Sprintf("/%s/%s", svcDesc.FullName(), methodDesc.Name())
82+
83+
// Invoke the gRPC method.
84+
respMsg := dynamicpb.NewMessage(methodDesc.Output())
85+
if err := s.conn.Invoke(ctx, fullMethod, reqMsg, respMsg, grpc.StaticMethod()); err != nil {
86+
return nil, nil, fmt.Errorf("gRPC invoke %s: %w", fullMethod, err)
87+
}
88+
89+
// Marshal response back to JSON.
90+
marshaler := protojson.MarshalOptions{
91+
Multiline: true,
92+
Indent: " ",
93+
EmitUnpopulated: true,
94+
}
95+
respJSON, err := marshaler.Marshal(respMsg)
96+
if err != nil {
97+
return nil, nil, fmt.Errorf("marshal response: %w", err)
98+
}
99+
100+
return &gomcp.CallToolResult{
101+
Content: []gomcp.Content{&gomcp.TextContent{Text: string(respJSON)}},
102+
}, nil, nil
103+
}
104+
105+
// serviceKeyToProtoName converts a service registry key like
106+
// "battery/ManagerService" to the proto full service name
107+
// "battery.ManagerService".
108+
func serviceKeyToProtoName(key string) string {
109+
idx := strings.Index(key, "/")
110+
if idx < 0 {
111+
return key
112+
}
113+
return key[:idx] + "." + key[idx+1:]
114+
}
115+
116+
// findServiceDescriptor looks up a service by its proto full name in the
117+
// global file registry.
118+
func findServiceDescriptor(fullName string) (protoreflect.ServiceDescriptor, error) {
119+
desc, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(fullName))
120+
if err != nil {
121+
return nil, fmt.Errorf("proto service %q not found in registry: %w", fullName, err)
122+
}
123+
svcDesc, ok := desc.(protoreflect.ServiceDescriptor)
124+
if !ok {
125+
return nil, fmt.Errorf("%q is a %T, not a service descriptor", fullName, desc)
126+
}
127+
return svcDesc, nil
128+
}

0 commit comments

Comments
 (0)